Packages

Lucerna SDK for Elixir — feature gates, experiments and identity.

Current section

Files

Jump to
lucerna guides phoenix.md
Raw

guides/phoenix.md

# 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:
```elixir
# 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.
```elixir
# 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:
```elixir
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.
```elixir
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
```
```heex
<.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:
```elixir
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:
```elixir
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`:
```elixir
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.