Skip to content

Ruby SDK

The lucerna gem checks flags, kill switches and experiments in your Ruby server, and identifies users into People. It downloads your rules once, keeps them fresh in the background, and answers every check from memory.

Pure Ruby, zero runtime dependencies, Ruby 3.2+. Rails, Sinatra, Sidekiq, scripts.

Setup

Configure once, before the first check:

ruby
# config/initializers/lucerna.rb
Lucerna.configure do |config|
  config.server_key = ENV["LUCERNA_GATES_KEY"]
  config.on_error = ->(error) { Rails.logger.warn("gates refresh failed: #{error.message}") }
end

Lucerna.gates and Lucerna.identity are lazy singletons — the first access builds them and starts the background work. Configuring after that raises, so a config change can never be a silent no-op.

Options

OptionDefaultWhat it does
server_keyYour server key (ck_srv_…). Required.
base_urlhttps://api.uselucerna.appWhere to fetch rules from. Override for local development.
refresh_interval10.0 secondsHow often rules refresh. Leave it — 10s is what makes kill switches fast.
request_timeout5.0 secondsTimeout per request.
on_errorCalled when background work fails. Wire it to your logging.
loggerRails: Rails.loggerUsed at warn when on_error isn't set.
track_exposurestrueReport experiment exposures to Lucerna. Turn off in tests.
identity_keyserver_keySeparate key for Lucerna.identity, if you scope keys per product.
transportNet::HTTPCustom transport, mostly for tests.
eager_initfalseRails only: warm the rules right after boot instead of on the first check.

Who is the user?

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

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

Trait values may be strings, symbols, numbers or booleans — they are stored as strings (seats: 12 becomes "12"). A plain { user_id:, traits: } hash works anywhere an identity does, and nil means anonymous.

One identity per request

On a server, build the identity inside the request handler. A constant or memoized 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:

MethodReturnsIf 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:

ruby
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: '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 gem 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.
  • 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.identity.identify(identity) upserts the user into People — that's what powers audiences and trait targeting. It validates, queues, and returns immediately; a background thread delivers.

ruby
Lucerna.identity.identify(identity)
  • Bad input (user_id missing, an array as a trait) raises ArgumentError — that's a bug in your code, not a network problem.
  • Delivery never raises. A failed send reports to on_error, and the next identify for that user is the retry — the server upserts idempotently.
  • Lucerna.identity.flush(timeout: 2) blocks until queued identifies were attempted, if you need that (tests, end of a script).

wait_until_ready and close

  • Lucerna.gates.wait_until_ready(timeout: 5) — 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.
  • Lucerna.close — stops the background refresh and sends any queued events. Registered via at_exit automatically; call it yourself only if you manage shutdown explicitly.

When things go wrong

The gem 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, and on_error fires on each failed refresh.

Wire on_error to your logging so you notice.

Forking servers

Puma in cluster mode, Unicorn and Sidekiq fork worker processes, and threads don't survive a fork. You don't have to care: the first check in each worker notices, keeps the already-downloaded rules, and restarts the background refresh. Pending events from the parent are dropped, never replayed.

To skip even that first-check hiccup, restart eagerly:

ruby
# puma.rb
on_worker_boot { Lucerna.restart }

# config/initializers/sidekiq.rb
Sidekiq.configure_server { |config| config.on(:startup) { Lucerna.restart } }

Full example

ruby
# config/initializers/lucerna.rb
Lucerna.configure do |config|
  config.server_key = ENV["LUCERNA_GATES_KEY"]
  config.on_error = ->(error) { Rails.logger.warn("lucerna: #{error.message}") }
end
ruby
class CheckoutController < ApplicationController
  def show
    identity = Lucerna::Identity.new(
      user_id: current_user.id.to_s,
      email: current_user.email,
      traits: { plan: current_user.plan },
    )
    Lucerna.identity.identify(identity)

    unless Lucerna.gates.switch("payments")
      return render json: { error: "payments_paused" }, status: 503
    end

    @variant = Lucerna.gates.experiment("checkout_test", identity) || "control"
    @show_new_billing = Lucerna.gates.flag("new_billing", identity)
  end
end

See evaluation semantics for how decisions are made — the Ruby engine is bit-for-bit identical to every other SDK.

Lucerna Developer Docs