Skip to content

Elixir SDK

The lucerna hex package checks flags, kill switches and experiments in your Elixir app, and identifies users into People. It downloads your rules once, keeps them fresh in the background, and answers every check from memory — an ETS lookup plus a pure evaluation, no process on the hot path.

Elixir 1.18+. Two runtime dependencies by design: finch for HTTP, telemetry for instrumentation. Full module docs live on HexDocs.

Setup

Add one child to your supervision tree:

elixir
# lib/my_app/application.ex
children = [
  {Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")}
]

Options are validated at boot — a bad or unknown option raises immediately, never later. There is no application-env configuration by design: options go to the child spec.

Options

Times are milliseconds (BEAM convention) — porting from the Ruby gem, its refresh_interval: 10 is refresh_interval: 10_000 here.

OptionDefaultWhat it does
:server_keyYour server key (ck_srv_…). Required; the publishable ck_client_… key is rejected.
:refresh_interval10_000How often rules refresh. Leave it — 10s is what makes kill switches fast. Floored at 1s.
:request_timeout5_000Timeout per request.
:track_exposurestrueReport experiment exposures to Lucerna. Turn off in tests.
:identity_key:server_keySeparate key for Lucerna.identify/1, if you scope keys per product.
:nameLucernaInstance name. Give each a name to run isolated instances; pass name: to reads.
:httpFinch adapterCustom transport ({adapter, opts} implementing Lucerna.HTTP), mostly for tests.

Who is the user?

Build one Lucerna.Identity per request and pass it to every check — the same value also feeds identify:

elixir
identity = Lucerna.Identity.new!(
  user_id: user.public_id,   # your app's id — never an email
  email: user.email,         # optional; only identify sends it
  name: user.name,           # optional; only identify sends it
  traits: %{plan: "pro", seats: 12}
)

Trait values may be strings, atoms, numbers or booleans — they are stored as strings (seats: 12 becomes "12"). Gates reads are lenient: a plain %{user_id: …, traits: …} map or keyword list works anywhere an identity does, and nil means anonymous. Lucerna.Identity.new!/1 itself is strict — a missing or empty user_id raises ArgumentError, because that's a bug in your code.

One identity per request

On a server, build the identity inside the request handler — in Phoenix, a plug that assigns it to the conn (see the Phoenix guide). A module attribute or cached identity is shared by every request — everyone gets targeted as the same user.

Gates evaluation is local: nothing about the user is sent anywhere. The email is used only to derive the domain for domain overrides — identify is the single call that transmits it.

Checking gates

All checks are instant and never raise. Every read takes name: MyName in opts for non-default instances:

FunctionReturnsIf unknown or not loaded
Lucerna.Gates.flag(key, identity)true / falsefalse
Lucerna.Gates.experiment(key, identity)variant name or nilnil
Lucerna.Gates.switch(key)false means killedtrue (not killed)
Lucerna.Gates.evaluate(identity)every decision at onceempty set
Lucerna.Gates.inspect_gate(key, identity)decision + explanation%{key: …, loaded: false}

Why did I get this answer?

inspect_gate shows the decision and how it was made — useful in logs and support tooling:

elixir
Lucerna.Gates.inspect_gate("new_billing", identity)
# %{
#   key: "new_billing",
#   loaded: true,
#   flag: %{
#     decision: %{on: false, reason: :rollout},
#     trace: [
#       %{step: "enabled", detail: "flag is on in this environment"},
#       %{step: "condition", detail: ~s(plan is "pro": no match)},
#       %{step: "rollout", detail: "bucket 7412 >= threshold 2500 (basis points of 10000)"}
#     ]
#   }
# }

Experiment results

When your code reads a variant with experiment, the SDK tells Lucerna that user saw the test — that's how the results page counts who was in each variant. You don't have to do anything.

It's built to stay out of your way:

  • Recorded only when you read a variant — never on refreshes.
  • Sent in the background in batches. Reads stay instant — recording is an async cast, and a dead reporting process never breaks a read.
  • Each user counts once. Re-sending is harmless.
  • If the network is down, events are dropped, never queued forever.

Set track_exposures: false to turn it off (in tests, for example).

Identifying users

Lucerna.identify(identity) upserts the user into People — that's what powers audiences and trait targeting. This is the only call that transmits email/name; gates reads send nothing.

elixir
Lucerna.identify(identity)
  • Bad input (a missing user_id, a list as a trait) raises ArgumentError — that's a bug in your code, not a network problem.
  • Delivery is async and fire-and-forget: 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() blocks until queued events and identifies were attempted, if you need that (tests, end of a script).

wait_until_ready and shutdown

  • Lucerna.Gates.wait_until_ready(timeout: 5_000) — returns true once your rules are loaded, false on timeout. Raises Lucerna.AuthError if the key is wrong, so a bad deploy fails loudly. Optional: checks are safe to call before it — a web request should never block on it.
  • Shutdown: nothing to call. On a clean stop the supervisor drains pending events (under a 2-second window) before the HTTP pool goes down.

When things go wrong

The SDK never crashes your app:

  • Rules haven't loaded yet (or the key is wrong): checks answer safe defaults — flags false, experiments nil, kill switches not killed.
  • Lucerna becomes unreachable later: checks keep answering from the last downloaded rules. Stale beats default — a failing poll never degrades reads to "off", and a crashed poller can't take the snapshot down (it's supervised separately from the table).

Observe with telemetry

There is no on_error callback and no logging — the SDK emits :telemetry events instead. Attach them to your existing pipeline:

  • [:lucerna, :gates, :evaluation] — every point read, with %{type, key, reason, decision} metadata.
  • [:lucerna, :gates, :snapshot] — a new runtime landed.
  • [:lucerna, :sync, :success | :error] — each poll.
  • [:lucerna, :reporting, :flush | :error] — exposure and identify delivery.

Wire [:lucerna, :sync, :error] and [:lucerna, :reporting, :error] to your logging so you notice.

Phoenix and LiveView

The child spec above is the Phoenix integration — there's no extra package and no plug to install. The Phoenix guide covers the patterns: build the identity once per request in a plug, evaluate in controllers (not in HEEx), and identify on login.

One caveat worth knowing before you need it: a gate read in a LiveView's mount is frozen for the life of that process. Fine for flags — wrong for kill switches, which are meant to bite immediately. Re-evaluate guarded paths on a timer; the guide shows how.

Full example

elixir
# lib/my_app/application.ex
children = [
  MyApp.Repo,
  {Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")},
  MyAppWeb.Endpoint
]
elixir
defmodule MyAppWeb.CheckoutController do
  use MyAppWeb, :controller

  def show(conn, _params) do
    identity = conn.assigns.identity  # built once per request, in a plug

    if Lucerna.Gates.switch("payments") do
      conn
      |> assign(:variant, Lucerna.Gates.experiment("checkout_test", identity) || "control")
      |> assign(:show_new_billing, Lucerna.Gates.flag("new_billing", identity))
      |> render(:show)
    else
      conn |> put_status(503) |> json(%{error: "payments_paused"})
    end
  end
end

See evaluation semantics for how decisions are made — the Elixir engine is bit-for-bit identical to every other SDK, verified against the same golden vectors.

Lucerna Developer Docs