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

FunctionReturnsOne line
Lucerna.Gates.flag(key, identity \\ nil, opts \\ [])booleanIs 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 nilReturns the assigned variant name, or nil when unassigned. This is the only read that records an exposure.
Lucerna.Gates.switch(key, opts \\ [])booleanReturns 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 \\ [])mapReturns 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 \\ [])mapReturns a debug projection across all three gate kinds with the rule-by-rule trace. Does not record exposures.
Lucerna.Gates.runtime(opts \\ [])map or nilReturns the last-known compiled runtime document, or nil before the first successful download.
Lucerna.Gates.wait_until_ready(opts \\ [])booleanBlocks until the first download lands. Optional in production — reads are safe before it returns.
Lucerna.identify(identity, opts \\ []):okSelf-syncs an identity to People. This is the only path that transmits email or name.
Lucerna.flush(opts \\ []):okDelivers 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_one and 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 same sdks/core/src/vectors.json golden 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/name are first-class identity fields, never traits, and are sent only by Lucerna.identify/1.

Errors & failure modes

  • wait_until_ready/1 returns true once the first download lands, false on timeout, and raises Lucerna.AuthError if 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.Reporting flushes its queue under a shutdown: 2_000 window before the Finch pool goes down, so exposures aren't lost on a clean stop.
  • Observe, don't log. The SDK emits :telemetry rather 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).

OptionDefaultMeaning
:server_key(required)Server API key (ck_srv_… or ck_key_…). Browser keys (ck_client_…) are rejected at construction.
:base_urlhttps://api.uselucerna.appAPI host. Override this for local development against a different worker.
:refresh_interval10_000Poll cadence in milliseconds. Values below 1_000 are floored to 1_000.
:request_timeout5_000Per-request timeout in milliseconds for runtime, events, and identify calls.
:track_exposurestrueWhen true, variant reads on experiment/3 record exposures for delivery.
:identity_key:server_keyAPI key used by Lucerna.identify/1. Defaults to the same value as :server_key.
:nameLucernaInstance 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)