Lucerna SDK for Elixir
Feature gates, experiments and identity for Elixir and Phoenix apps — the lucerna hex package. The server-side member of the Lucerna SDK family (the shared evaluator ships as @lucerna/gates-core; the JS and Ruby server SDKs are the siblings), expressed as an OTP application: one child in your supervision tree, then read anywhere with no process on the hot path.
Install
# mix.exs
def deps do
[{:lucerna, "~> 0.0.1-alpha.0"}]
end
Requires Elixir ~> 1.18 (the SDK uses the standard-library JSON module). Two runtime dependencies, by design: finch for HTTP, telemetry for instrumentation.
You need a server key (ck_srv_… or ck_key_…) from your environment's settings. Browser keys (ck_client_…) are rejected at construction — they can't download gate rules.
Quickstart
Add one child to your supervision tree:
children = [
{Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")}
]
Then read anywhere — build the identity once per request, pass it in:
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 — email/name travel here only
Using Phoenix? See the Phoenix guide for the identity-plug, edge-evaluate-then-assign, and LiveView patterns.
API
All reads live under Lucerna.Gates and take an optional identity (a Lucerna.Identity, a loose map/keyword, or nil for anonymous) plus opts (name: for non-default instances).
| Function | Returns | One line |
|---|---|---|
Lucerna.Gates.flag(key, identity \\ nil, opts \\ []) | boolean | Is the flag on for this identity? Unknown keys and a runtime that has not loaded yet both return false. |
Lucerna.Gates.experiment(key, identity \\ nil, opts \\ []) | String.t() or nil | Returns the assigned variant name, or nil when unassigned. This is the only read that records an exposure. |
Lucerna.Gates.switch(key, opts \\ []) | boolean | Returns false when the guarded path is killed. Unknown keys and a runtime that has not loaded yet both return true. |
Lucerna.Gates.evaluate(identity \\ nil, opts \\ []) | map | Returns one identity's decisions over the whole runtime for SSR or client bootstrap. Does not record exposures. |
Lucerna.Gates.inspect_gate(key, identity \\ nil, opts \\ []) | map | Returns a debug projection across all three gate kinds with the rule-by-rule trace. Does not record exposures. |
Lucerna.Gates.runtime(opts \\ []) | map or nil | Returns the last-known compiled runtime document, or nil before the first successful download. |
Lucerna.Gates.wait_until_ready(opts \\ []) | boolean | Blocks until the first download lands. Optional in production — reads are safe before it returns. |
Lucerna.identify(identity, opts \\ []) | :ok | Self-syncs an identity to People. This is the only path that transmits email or name. |
Lucerna.flush(opts \\ []) | :ok | Delivers queued exposures and identifies immediately. Useful in scripts and tests. |
Lucerna.Identity.new!(fields) | Lucerna.Identity.t() | Builds a validated identity. user_id is required and must be non-empty. |
See Lucerna.Config for every start option.
Guarantees & semantics
- Client decisions are UX hints, never authorization. Anything entitlement-shaped is re-checked server-side. A flag being on in the SDK is not permission to do the thing.
- Reads never raise, and fail open. Before the first snapshot, or against a bad identity, or on a malformed rule: flag →
false, experiment →nil, switch →true(not killed), evaluate → empty. A read is an ETS lookup and a pure evaluation — nothing to raise. - Stale beats default; default beats crash. Once a snapshot exists, sync failures keep serving the last-known table — a crashed poller never degrades reads to "off". A Sync crash cannot take the snapshot down: the supervisor is
one_for_oneand the Store owns the table. - Bucketing is frozen. murmur3 (x86 32-bit) over
${salt}:${unitId}, basis points (hash % 10000), stored salts — the same evaluator and the samesdks/core/src/vectors.jsongolden vectors as every other Lucerna SDK. Renaming a key never reshuffles users. - Kills propagate within the poll window. A kill switch flips in the runtime and the next poll (≤
refresh_interval, default 10s) picks it up. In LiveView, re-evaluate on a timer — see the Phoenix guide. - PII stays put. Gates reads transmit nothing.
email/nameare first-class identity fields, never traits, and are sent only byLucerna.identify/1.
Errors & failure modes
wait_until_ready/1returnstrueonce the first download lands,falseon timeout, and raisesLucerna.AuthErrorif the first settle was a 401/403 — a bad key should be loud. Network errors and 5xx keep the poll retrying; a fixed key recovers without a restart.- Delivery is fire-and-forget. Exposure batches and identify posts get one attempt, no retry — the server ingests idempotently, so the next batch/identify is the retry. Failures fire
[:lucerna, :reporting, :error]telemetry and drop. - Shutdown drains. On termination,
Lucerna.Reportingflushes its queue under ashutdown: 2_000window before the Finch pool goes down, so exposures aren't lost on a clean stop. - Observe, don't log. The SDK emits
:telemetryrather than logging. Events:[:lucerna, :gates, :evaluation],[:lucerna, :gates, :snapshot],[:lucerna, :sync, :success | :error],[:lucerna, :reporting, :flush | :error]. Metadata shapes are on each module's docs.
Configuration
Options go to the child spec — there is no application-env config by design. Times are milliseconds (BEAM convention).
| Option | Default | Meaning |
|---|---|---|
:server_key | (required) | Server API key (ck_srv_… or ck_key_…). Browser keys (ck_client_…) are rejected at construction. |
:base_url | https://api.uselucerna.app | API host. Override this for local development against a different worker. |
:refresh_interval | 10_000 | Poll cadence in milliseconds. Values below 1_000 are floored to 1_000. |
:request_timeout | 5_000 | Per-request timeout in milliseconds for runtime, events, and identify calls. |
:track_exposures | true | When true, variant reads on experiment/3 record exposures for delivery. |
:identity_key | :server_key | API key used by Lucerna.identify/1. Defaults to the same value as :server_key. |
:name | Lucerna | Instance name. Everything derives from it — run isolated instances by giving each a name and passing name: to reads. |
Development
mix test # includes the golden-vector parity suite
mix format --check-formatted
mix docs # build HexDocs locally
LUCERNA_E2E=1 mix test test/e2e_dogfood_test.exs # live dogfood (needs the API worker)