Packages

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

Current section

Files

Jump to
lucerna README.md
Raw

README.md

# 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
```elixir
# 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:
```elixir
children = [
{Lucerna, server_key: System.fetch_env!("LUCERNA_SERVER_KEY")}
]
```
Then read anywhere — build the identity once per request, pass it in:
```elixir
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](guides/phoenix.md) 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_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).
| 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
```sh
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)
```