lucerna
Server-side Lucerna SDK for Ruby: local Gates evaluation (feature flags, experiments, kill switches) with a background runtime poll, and fire-and-forget Identity delivery into People. Pure Ruby, zero runtime dependencies, Ruby >= 3.2. Works in any Ruby process — Rails (a Railtie is included), Sinatra, Sidekiq, plain scripts.
It takes a server key (ck_srv_… or ck_key_…). A publishable browser key (ck_client_…) cannot download gates rules and is rejected at construction. Never put a server key in code that ships to a client.
This is the Ruby sibling of @lucerna-dev/gates-node + @lucerna-dev/identity: same wire protocol, same frozen bucketing contract, same golden vectors.
Install
Not yet published to RubyGems — point Bundler at the monorepo:
gem "lucerna", path: "../lucerna/sdks/ruby"
Quickstart
require "lucerna"
Lucerna.configure do |config|
config.server_key = ENV["LUCERNA_SERVER_KEY"] # ck_srv_… or ck_key_…, never ck_client_…
end
# One identity per request feeds both products.
identity = Lucerna::Identity.new(
user_id: "u_42", # pseudonymous app id — never an email
email: "sofia@acme.com", # PII: first-class field, only identify transmits it
traits: { plan: "pro" },
)
Lucerna.gates.wait_until_ready(timeout: 5) # optional — reads are safe before ready
enabled = Lucerna.gates.flag("new_billing", identity) # => true / false
variant = Lucerna.gates.experiment("checkout_v2", identity) # => "treatment", … or nil
healthy = Lucerna.gates.switch("payments") # => false means killed
Lucerna.identity.identify(identity) # fire-and-forget upsert into People
In Rails, put the Lucerna.configure block in config/initializers/lucerna.rb. The Railtie defaults Lucerna.config.logger to Rails.logger.
Documentation
- Getting started
- Reference — every option, method, contract, error
API
| Export | Signature | What it does | ||
|---|---|---|---|---|
Lucerna.configure |
`{ \ | config\ | … }` | Set config before first use. Raises if a client was already built. |
Lucerna.gates |
→ Gates::Client |
Lazy singleton, built from config on first access. | ||
Lucerna.identity |
→ Identity::Client |
Lazy singleton for identify delivery. | ||
Lucerna.close |
(timeout: 2) |
Stop polling/delivery, flush queues. Also runs via at_exit. |
||
Lucerna.restart |
Abandon clients after a fork (on_worker_boot); next access rebuilds. |
|||
Lucerna::Identity.new |
(user_id:, email: nil, name: nil, traits: {}) |
Immutable identity value; validates strictly. | ||
Gates::Client#flag |
(key, identity = nil) → bool |
Flag decision. Unknown key / pre-load: false. |
||
Gates::Client#experiment |
(key, identity = nil) → String? |
Variant name or nil. The only exposure-recording read. |
||
Gates::Client#switch |
(key) → bool |
Kill switch. false means killed. Unknown: true. |
||
Gates::Client#evaluate |
(identity = nil) → Hash |
All decisions (SSR/bootstrap). No exposures. | ||
Gates::Client#inspect_gate |
(key, identity = nil) → Hash |
Decision + rule-by-rule trace. No exposures. | ||
Gates::Client#wait_until_ready |
(timeout: nil) → bool |
True after first load; raises AuthError on 401/403. |
||
Gates::Client#close |
(timeout: 2) |
Stop the poll, flush exposures. Stale reads keep answering. | ||
Identity::Client#identify |
(identity) or kwargs |
Validates, enqueues, returns immediately. | ||
Identity::Client#flush |
(timeout: 2) → bool |
Block until queued identifies were attempted. |
Anywhere an identity is accepted: a Lucerna::Identity, a { user_id:, email:, traits: } hash, or nil (anonymous).
Guarantees & semantics
- Gate decisions are UX hints, never authorization. Anything entitlement-shaped is re-checked server-side.
- Reads never raise and fail open: flag →
false, experiment →nil, switch →true(not killed). Last-known beats default — stale beats default, default beats crash. - Bucketing is frozen: murmur3 (x86 32-bit) over
"#{salt}:#{unit_id}", basis points (hash % 10000), stored salts. Renaming a key never reshuffles users. The golden vectors insdks/core/src/vectors.jsonare the append-only cross-language contract and run in this gem's test suite. - Startup download + 10s ETag poll — kill switches propagate in ≤10s (poll) + ≤10s (server cache). The runtime swap is atomic: readers see the whole old or whole new document, never a torn one.
- Exposure-on-read: only
experiment()records, only when a variant was assigned to a known user. Deduped locally (10 min), batched (≤100/request), delivered on the poll tick, flushed on close.track_exposures: falseopts out. - Unknown rule elements fail safe: a gate carrying an operator or override kind this version doesn't know answers whole with
unsupported_rule— flag off, experiment unassigned. - Fork-safe: after
Process.fork(Puma cluster, Unicorn, Sidekiq), the first read detects the dead poller and respawns it, starting warm from the inherited runtime; inherited exposure/identify backlogs are dropped, never replayed.Lucerna.restartinon_worker_boot/after_forkis optional and merely avoids the first-read latency blip. - PII discipline:
user_idis a pseudonymous app id.email/nameare first-class fields — never traits — and onlyidentifytransmits them. Gates evaluation is local and transmits nothing; exposure events carry only theuser_id.
Errors & failure modes
wait_until_readyreturnstrueon first successful load,falseon timeout, and raisesLucerna::AuthErrorif the first settle was a 401/403 (bad key — discoverable, and sticky). The poll continues regardless: a key fixed later recovers reads without a restart.- Network errors and 5xx retry inside a fetch (3 attempts, jittered backoff), then the poll retries every interval. Any 4xx is a stable answer and fails the attempt immediately.
- All background failures route to
config.on_error(orconfig.loggeratwarn). A raisingon_erroris swallowed — it can never take down a read or a worker thread. identifyraisesArgumentErroron invalid input (programming error); delivery never raises — one attempt, no retry, because the server upserts idempotently and the next identify is the retry.- A runtime document with a newer
schemaVersionis rejected (upgrade the gem); the current runtime keeps answering.