lucerna 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf8a610323ba01503dfe245ea129a8cdedfc2d9e916977fd34ce7cc1c85d04d3
4
+ data.tar.gz: 9c4c0c931b3bc9326a696d849873cd0741d546194eacdd4d038152e994bdb168
5
+ SHA512:
6
+ metadata.gz: d3cedc4e00127c55d42fb9436acb15570d38f206f1aa2d2cce1c578fdf691a713b19d6940164b2bf35de6c012550b9894f432ca275027d7f74bf68825b7e6a59
7
+ data.tar.gz: 25c3407e10189105c8cbf1f37ac7b4513303308e669fcfed5409c24a949f913c273c36c1ac1684d3d8d186c25e528a0e7596d96821a07a00297b5510408926cc
data/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # lucerna
2
+
3
+ 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.
4
+
5
+ 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.
6
+
7
+ This is the Ruby sibling of `@lucerna-dev/gates-node` + `@lucerna-dev/identity`: same wire protocol, same frozen bucketing contract, same golden vectors.
8
+
9
+ ## Install
10
+
11
+ Not yet published to RubyGems — point Bundler at the monorepo:
12
+
13
+ ```ruby
14
+ gem "lucerna", path: "../lucerna/sdks/ruby"
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ruby
20
+ require "lucerna"
21
+
22
+ Lucerna.configure do |config|
23
+ config.server_key = ENV["LUCERNA_SERVER_KEY"] # ck_srv_… or ck_key_…, never ck_client_…
24
+ end
25
+
26
+ # One identity per request feeds both products.
27
+ identity = Lucerna::Identity.new(
28
+ user_id: "u_42", # pseudonymous app id — never an email
29
+ email: "sofia@acme.com", # PII: first-class field, only identify transmits it
30
+ traits: { plan: "pro" },
31
+ )
32
+
33
+ Lucerna.gates.wait_until_ready(timeout: 5) # optional — reads are safe before ready
34
+
35
+ enabled = Lucerna.gates.flag("new_billing", identity) # => true / false
36
+ variant = Lucerna.gates.experiment("checkout_v2", identity) # => "treatment", … or nil
37
+ healthy = Lucerna.gates.switch("payments") # => false means killed
38
+
39
+ Lucerna.identity.identify(identity) # fire-and-forget upsert into People
40
+ ```
41
+
42
+ In Rails, put the `Lucerna.configure` block in `config/initializers/lucerna.rb`. The Railtie defaults `Lucerna.config.logger` to `Rails.logger`.
43
+
44
+ ## Documentation
45
+
46
+ - [Getting started](docs/getting-started.md)
47
+ - [Reference](docs/references.md) — every option, method, contract, error
48
+
49
+ ## API
50
+
51
+ | Export | Signature | What it does |
52
+ | -------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
53
+ | `Lucerna.configure` | `{ \|config\| … }` | Set config before first use. Raises if a client was already built. |
54
+ | `Lucerna.gates` | → `Gates::Client` | Lazy singleton, built from config on first access. |
55
+ | `Lucerna.identity` | → `Identity::Client` | Lazy singleton for identify delivery. |
56
+ | `Lucerna.close` | `(timeout: 2)` | Stop polling/delivery, flush queues. Also runs via `at_exit`. |
57
+ | `Lucerna.restart` | | Abandon clients after a fork (`on_worker_boot`); next access rebuilds. |
58
+ | `Lucerna::Identity.new` | `(user_id:, email: nil, name: nil, traits: {})` | Immutable identity value; validates strictly. |
59
+ | `Gates::Client#flag` | `(key, identity = nil)` → bool | Flag decision. Unknown key / pre-load: `false`. |
60
+ | `Gates::Client#experiment` | `(key, identity = nil)` → String? | Variant name or `nil`. The only exposure-recording read. |
61
+ | `Gates::Client#switch` | `(key)` → bool | Kill switch. `false` means killed. Unknown: `true`. |
62
+ | `Gates::Client#evaluate` | `(identity = nil)` → Hash | All decisions (SSR/bootstrap). No exposures. |
63
+ | `Gates::Client#inspect_gate` | `(key, identity = nil)` → Hash | Decision + rule-by-rule trace. No exposures. |
64
+ | `Gates::Client#wait_until_ready` | `(timeout: nil)` → bool | True after first load; raises `AuthError` on 401/403. |
65
+ | `Gates::Client#close` | `(timeout: 2)` | Stop the poll, flush exposures. Stale reads keep answering. |
66
+ | `Identity::Client#identify` | `(identity)` or kwargs | Validates, enqueues, returns immediately. |
67
+ | `Identity::Client#flush` | `(timeout: 2)` → bool | Block until queued identifies were attempted. |
68
+
69
+ Anywhere an identity is accepted: a `Lucerna::Identity`, a `{ user_id:, email:, traits: }` hash, or `nil` (anonymous).
70
+
71
+ ## Guarantees & semantics
72
+
73
+ - **Gate decisions are UX hints, never authorization.** Anything entitlement-shaped is re-checked server-side.
74
+ - **Reads never raise** and fail open: flag → `false`, experiment → `nil`, switch → `true` (not killed). Last-known beats default — stale beats default, default beats crash.
75
+ - **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 in `sdks/core/src/vectors.json` are the append-only cross-language contract and run in this gem's test suite.
76
+ - **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.
77
+ - **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: false` opts out.
78
+ - **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.
79
+ - **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.restart` in `on_worker_boot` / `after_fork` is optional and merely avoids the first-read latency blip.
80
+ - **PII discipline**: `user_id` is a pseudonymous app id. `email` / `name` are first-class fields — never traits — and only `identify` transmits them. Gates evaluation is local and transmits nothing; exposure events carry only the `user_id`.
81
+
82
+ ## Errors & failure modes
83
+
84
+ - `wait_until_ready` returns `true` on first successful load, `false` on timeout, and raises `Lucerna::AuthError` if 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.
85
+ - 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.
86
+ - All background failures route to `config.on_error` (or `config.logger` at `warn`). A raising `on_error` is swallowed — it can never take down a read or a worker thread.
87
+ - `identify` raises `ArgumentError` on invalid input (programming error); delivery never raises — one attempt, no retry, because the server upserts idempotently and the next identify is the retry.
88
+ - A runtime document with a newer `schemaVersion` is rejected (upgrade the gem); the current runtime keeps answering.
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ # Configuration for the module-level singletons. Set via
5
+ # Lucerna.configure before the first use of Lucerna.gates /
6
+ # Lucerna.identity.
7
+ class Config
8
+ # Required for Lucerna.gates: a server key (ck_srv_… / ck_key_…).
9
+ attr_accessor :server_key
10
+ # Defaults to https://api.uselucerna.app.
11
+ attr_accessor :base_url
12
+ # Runtime poll cadence in seconds. Default 10, floored at 1.
13
+ attr_accessor :refresh_interval
14
+ # Per-request timeout in seconds. Default 5.
15
+ attr_accessor :request_timeout
16
+ # callable(Error) — failures from polling and delivery. Reads never raise.
17
+ attr_accessor :on_error
18
+ # Used at :warn when on_error is not set. The Railtie defaults this to
19
+ # Rails.logger.
20
+ attr_accessor :logger
21
+ # Record experiment exposures on variant reads. Default true.
22
+ attr_accessor :track_exposures
23
+ # Key for Lucerna.identity; defaults to server_key. Any key granted
24
+ # people:identify works.
25
+ attr_accessor :identity_key
26
+ # Injectable transport (test seam): responds to call(request, timeout:).
27
+ attr_accessor :transport
28
+ # Warm Lucerna.gates after Rails boot instead of on first read.
29
+ # Default false — lazy init is fork-safe by construction.
30
+ attr_accessor :eager_init
31
+
32
+ def initialize
33
+ @track_exposures = true
34
+ @eager_init = false
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lucerna
4
+ class Error < StandardError; end
5
+
6
+ # A transport or API failure. +status+ is nil for network-level failures
7
+ # (timeout, refused connection), the HTTP status otherwise.
8
+ class RequestError < Error
9
+ attr_reader :status
10
+
11
+ def initialize(message, status: nil)
12
+ super(message)
13
+ @status = status
14
+ end
15
+ end
16
+
17
+ # 401/403 — the key is unknown, revoked, or lacks the required grant.
18
+ class AuthError < RequestError; end
19
+ end
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../errors"
6
+ require_relative "../http"
7
+ require_relative "../reporting"
8
+ require_relative "../traits"
9
+ require_relative "../version"
10
+ require_relative "evaluate"
11
+ require_relative "exposures"
12
+ require_relative "runtime"
13
+
14
+ module Lucerna
15
+ module Gates
16
+ # The Gates server client: polls the compiled runtime in a background
17
+ # thread and evaluates locally. Mirrors @lucerna/gates-node
18
+ # (sdks/node/src/client.ts).
19
+ #
20
+ # Guarantees:
21
+ # - Reads never raise. Before the first load (or through sustained
22
+ # failure) they answer safe defaults: flag false, experiment nil,
23
+ # switch true, evaluate empty. Stale beats default, default beats
24
+ # crash.
25
+ # - The runtime swap is atomic — readers see the whole old or the
26
+ # whole new document, never a torn one.
27
+ # - Exposures are recorded on variant reads only, delivered on the
28
+ # poll tick, and are always best-effort.
29
+ # - Survives fork: reads in a forked child detect the dead poller and
30
+ # respawn it, starting warm from the inherited runtime.
31
+ class Client
32
+ DEFAULT_BASE_URL = "https://api.uselucerna.app"
33
+ DEFAULT_REFRESH_INTERVAL = 10.0
34
+ MIN_REFRESH_INTERVAL = 1.0
35
+ DEFAULT_REQUEST_TIMEOUT = 5.0
36
+
37
+ def initialize(server_key:, base_url: nil, refresh_interval: nil, request_timeout: nil,
38
+ on_error: nil, logger: nil, track_exposures: true, transport: nil, sleeper: nil,
39
+ background: true)
40
+ raise ArgumentError, "server_key is required" if server_key.nil? || server_key.to_s.empty?
41
+ if server_key.start_with?("ck_client_")
42
+ raise ArgumentError,
43
+ "ck_client_… is a publishable browser key and cannot download gates rules — " \
44
+ "use a server key (ck_srv_… or ck_key_…) from your environment settings"
45
+ end
46
+
47
+ @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
48
+ @refresh_interval = [(refresh_interval || DEFAULT_REFRESH_INTERVAL).to_f, MIN_REFRESH_INTERVAL].max
49
+ @request_timeout = (request_timeout || DEFAULT_REQUEST_TIMEOUT).to_f
50
+ @reporter = Reporting.reporter(on_error, logger)
51
+ @transport = transport || HTTP::NetHttpTransport.new
52
+ @sleeper = sleeper || HTTP::DEFAULT_SLEEPER
53
+ @headers = {
54
+ "Authorization" => "Bearer #{server_key}",
55
+ "Accept" => "application/json",
56
+ "x-lucerna-sdk" => SDK_IDENTITY,
57
+ }.freeze
58
+ @background = background
59
+
60
+ @runtime = nil
61
+ @etag = nil
62
+ @closed = false
63
+ @poller = nil
64
+ @pid = Process.pid
65
+
66
+ @lifecycle_mutex = Mutex.new
67
+ @wakeup = ConditionVariable.new
68
+ @ready_mutex = Mutex.new
69
+ @ready_cv = ConditionVariable.new
70
+ @ready_state = :pending
71
+ @ready_error = nil
72
+
73
+ @exposures =
74
+ if track_exposures
75
+ Exposures.new(
76
+ url: "#{@base_url}/sdk/v1/events",
77
+ headers: @headers,
78
+ transport: @transport,
79
+ timeout: @request_timeout,
80
+ on_error: @reporter,
81
+ )
82
+ end
83
+
84
+ start_poller if @background
85
+ end
86
+
87
+ # Blocks until the first runtime load succeeds (true), the timeout
88
+ # elapses (false), or the first settle was a 401/403 (raises
89
+ # AuthError — a bad key should be discoverable, exactly like the
90
+ # node ready() rejection). Optional to call: reads are safe before
91
+ # ready. Network errors and 5xx keep waiting — polling retries them.
92
+ def wait_until_ready(timeout: nil)
93
+ deadline = timeout && monotonic_now + timeout
94
+ @ready_mutex.synchronize do
95
+ loop do
96
+ case @ready_state
97
+ when :ready then return true
98
+ when :auth_failed then raise @ready_error
99
+ end
100
+ remaining = deadline && deadline - monotonic_now
101
+ return false if remaining && remaining <= 0
102
+
103
+ @ready_cv.wait(@ready_mutex, remaining)
104
+ end
105
+ end
106
+ end
107
+
108
+ # The flag's decision for this identity. Unknown key or no runtime
109
+ # yet: false.
110
+ def flag(key, identity = nil)
111
+ safe_read(false) do |runtime|
112
+ config = runtime["flags"][key]
113
+ next false unless config
114
+
115
+ user_id, traits = resolve_identity(identity)
116
+ Evaluate.flag(config, user_id: user_id, traits: traits)[:on]
117
+ end
118
+ end
119
+
120
+ # The assigned variant name, or nil (not assigned / unknown key / no
121
+ # runtime yet). This is the only read that records an exposure — and
122
+ # only when a variant was assigned to a known user.
123
+ def experiment(key, identity = nil)
124
+ safe_read(nil) do |runtime|
125
+ config = runtime["experiments"][key]
126
+ next nil unless config
127
+
128
+ user_id, traits = resolve_identity(identity)
129
+ assignment = Evaluate.experiment(config, runtime["audiences"], user_id: user_id, traits: traits)
130
+ variant = assignment[:variant]
131
+ record_exposure(key, assignment, user_id) if variant && user_id && !user_id.empty?
132
+ variant && variant[:name]
133
+ end
134
+ end
135
+
136
+ # Kill switch state — false means the guarded path IS killed.
137
+ # Unknown key or no runtime yet: true (not killed).
138
+ def switch(key)
139
+ safe_read(true) do |runtime|
140
+ state = runtime["kills"][key]
141
+ state.nil? ? true : state
142
+ end
143
+ end
144
+
145
+ # Debug projection of one key across all three gate kinds, with the
146
+ # rule-by-rule trail. Never records exposures.
147
+ def inspect_gate(key, identity = nil)
148
+ safe_read({ key: key, loaded: false }) do |runtime|
149
+ user_id, traits = resolve_identity(identity)
150
+ result = { key: key, loaded: true }
151
+
152
+ kill = runtime["kills"][key]
153
+ result[:kill] = kill unless kill.nil?
154
+
155
+ if (config = runtime["flags"][key])
156
+ trace = []
157
+ decision = Evaluate.flag(config, user_id: user_id, traits: traits, trace: trace)
158
+ result[:flag] = { decision: decision, trace: trace }
159
+ end
160
+
161
+ if (config = runtime["experiments"][key])
162
+ trace = []
163
+ assignment = Evaluate.experiment(
164
+ config, runtime["audiences"], user_id: user_id, traits: traits, trace: trace,
165
+ )
166
+ result[:experiment] = { assignment: assignment, trace: trace }
167
+ end
168
+
169
+ result
170
+ end
171
+ end
172
+
173
+ # One identity's decisions over the entire runtime — for SSR or
174
+ # bootstrapping a client. Never records exposures.
175
+ def evaluate(identity = nil)
176
+ safe_read({ kills: {}, flags: {}, experiments: {} }) do |runtime|
177
+ user_id, traits = resolve_identity(identity)
178
+ Evaluate.all(runtime, user_id: user_id, traits: traits)
179
+ end
180
+ end
181
+
182
+ # The last-known compiled runtime document (wire shape), or nil.
183
+ def runtime
184
+ ensure_live!
185
+ @runtime
186
+ end
187
+
188
+ # Stops the poll and flushes queued exposures. Reads keep answering
189
+ # from the last-known runtime.
190
+ def close(timeout: 2)
191
+ poller = @lifecycle_mutex.synchronize do
192
+ @closed = true
193
+ @wakeup.broadcast
194
+ @poller
195
+ end
196
+ poller&.join(timeout)
197
+ @exposures&.flush
198
+ nil
199
+ end
200
+
201
+ def closed?
202
+ @lifecycle_mutex.synchronize { @closed }
203
+ end
204
+
205
+ private
206
+
207
+ # -- reads ----------------------------------------------------------
208
+
209
+ def safe_read(fallback)
210
+ ensure_live!
211
+ snapshot = @runtime
212
+ return fallback if snapshot.nil?
213
+
214
+ begin
215
+ yield snapshot
216
+ rescue StandardError => error
217
+ @reporter.call(error)
218
+ fallback
219
+ end
220
+ end
221
+
222
+ # Accepts nil (anonymous), anything responding to #for_gates
223
+ # (Lucerna::Identity), or a hash with user_id/traits/email keys.
224
+ # Lenient by design — reads never raise.
225
+ def resolve_identity(identity)
226
+ return [nil, {}] if identity.nil?
227
+
228
+ if identity.respond_to?(:for_gates)
229
+ projection = identity.for_gates
230
+ return [projection[:user_id], projection[:traits]]
231
+ end
232
+
233
+ if identity.is_a?(Hash)
234
+ user_id = identity[:user_id] || identity["user_id"]
235
+ traits = Traits.normalize(identity[:traits] || identity["traits"])
236
+ email = identity[:email] || identity["email"]
237
+ traits["email"] = email.to_s if email && !traits.key?("email")
238
+ return [user_id&.to_s, traits]
239
+ end
240
+
241
+ [nil, {}]
242
+ end
243
+
244
+ def record_exposure(key, assignment, user_id)
245
+ return unless @exposures
246
+
247
+ variant = assignment[:variant]
248
+ hint = @exposures.record(
249
+ "type" => "exposure",
250
+ "experimentKey" => key,
251
+ "iteration" => assignment[:iteration],
252
+ "userId" => user_id,
253
+ "variantId" => variant[:id],
254
+ "variantName" => variant[:name],
255
+ )
256
+ wake_poller if hint
257
+ end
258
+
259
+ # -- polling --------------------------------------------------------
260
+
261
+ def start_poller
262
+ @pid = Process.pid
263
+ @poller = Thread.new { poll_loop }
264
+ @poller.name = "lucerna-gates-poller"
265
+ end
266
+
267
+ def poll_loop
268
+ loop do
269
+ refresh_once
270
+ flush_exposures
271
+ @lifecycle_mutex.synchronize do
272
+ return if @closed
273
+
274
+ @wakeup.wait(@lifecycle_mutex, @refresh_interval)
275
+ return if @closed
276
+ end
277
+ end
278
+ end
279
+
280
+ # One conditional fetch of the runtime. Rescues everything: errors
281
+ # report through on_error and the current runtime keeps answering.
282
+ # A 401/403 before the first success settles the ready gate as
283
+ # auth-failed (sticky) — but polling continues, so a key re-enabled
284
+ # later starts working without a restart.
285
+ def refresh_once
286
+ etag = @etag
287
+ headers = etag ? @headers.merge("If-None-Match" => etag) : @headers
288
+ request = HTTP::Request.new(:get, "#{@base_url}/sdk/v1/gates/runtime", headers, nil)
289
+
290
+ response = HTTP.with_retry(sleeper: @sleeper) { @transport.call(request, timeout: @request_timeout) }
291
+ return if response.status == 304
292
+
293
+ document = JSON.parse(response.body)
294
+ unless Runtime.acceptable?(document)
295
+ raise RequestError, "runtime document is not usable by this SDK version — upgrade the lucerna gem"
296
+ end
297
+
298
+ @lifecycle_mutex.synchronize do
299
+ @runtime = document
300
+ @etag = response.headers["etag"]
301
+ end
302
+ settle_ready(:ready)
303
+ rescue StandardError => error
304
+ @reporter.call(error)
305
+ settle_ready(:auth_failed, error) if error.is_a?(AuthError)
306
+ end
307
+
308
+ def flush_exposures
309
+ @exposures&.flush
310
+ rescue StandardError => error
311
+ @reporter.call(error)
312
+ end
313
+
314
+ def settle_ready(state, error = nil)
315
+ @ready_mutex.synchronize do
316
+ return unless @ready_state == :pending
317
+
318
+ @ready_state = state
319
+ @ready_error = error
320
+ @ready_cv.broadcast
321
+ end
322
+ end
323
+
324
+ def wake_poller
325
+ @lifecycle_mutex.synchronize { @wakeup.broadcast }
326
+ end
327
+
328
+ # -- fork / thread liveness -----------------------------------------
329
+
330
+ # Fast path: same process, live poller (or deliberately not running).
331
+ # After a fork the inherited poller thread doesn't exist in the
332
+ # child: keep the runtime (start warm), drop the inherited exposure
333
+ # queue (N workers must not replay the parent's backlog), respawn.
334
+ def ensure_live!
335
+ return unless @background
336
+ return if @closed
337
+ return if Process.pid == @pid && @poller&.alive?
338
+
339
+ @lifecycle_mutex.synchronize do
340
+ return if @closed
341
+ return if Process.pid == @pid && @poller&.alive?
342
+
343
+ @exposures&.clear if Process.pid != @pid
344
+ start_poller
345
+ end
346
+ end
347
+
348
+ # -- misc -----------------------------------------------------------
349
+
350
+ def monotonic_now
351
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
352
+ end
353
+ end
354
+ end
355
+ end