Class: Lucerna::Gates::Client
- Inherits:
-
Object
- Object
- Lucerna::Gates::Client
- Defined in:
- lib/lucerna/gates/client.rb
Overview
The Gates server client: polls the compiled runtime in a background thread and evaluates locally. Mirrors @lucerna/gates-node (sdks/node/src/client.ts).
Guarantees:
- Reads never raise. Before the first load (or through sustained failure) they answer safe defaults: flag false, experiment nil, switch true, evaluate empty. Stale beats default, default beats crash.
- The runtime swap is atomic — readers see the whole old or the whole new document, never a torn one.
- Exposures are recorded on variant reads only, delivered on the poll tick, and are always best-effort.
- Survives fork: reads in a forked child detect the dead poller and respawn it, starting warm from the inherited runtime.
Constant Summary collapse
- DEFAULT_BASE_URL =
"https://api.uselucerna.app"- DEFAULT_REFRESH_INTERVAL =
10.0- MIN_REFRESH_INTERVAL =
1.0- DEFAULT_REQUEST_TIMEOUT =
5.0
Instance Method Summary collapse
-
#close(timeout: 2) ⇒ Object
Stops the poll and flushes queued exposures.
- #closed? ⇒ Boolean
-
#evaluate(identity = nil) ⇒ Object
One identity's decisions over the entire runtime — for SSR or bootstrapping a client.
-
#experiment(key, identity = nil) ⇒ Object
The assigned variant name, or nil (not assigned / unknown key / no runtime yet).
-
#flag(key, identity = nil) ⇒ Object
The flag's decision for this identity.
-
#initialize(server_key:, base_url: nil, refresh_interval: nil, request_timeout: nil, on_error: nil, logger: nil, track_exposures: true, transport: nil, sleeper: nil, background: true) ⇒ Client
constructor
A new instance of Client.
-
#inspect_gate(key, identity = nil) ⇒ Object
Debug projection of one key across all three gate kinds, with the rule-by-rule trail.
-
#runtime ⇒ Object
The last-known compiled runtime document (wire shape), or nil.
-
#switch(key) ⇒ Object
Kill switch state — false means the guarded path IS killed.
-
#wait_until_ready(timeout: nil) ⇒ Object
Blocks until the first runtime load succeeds (true), the timeout elapses (false), or the first settle was a 401/403 (raises AuthError — a bad key should be discoverable, exactly like the node ready() rejection).
Constructor Details
#initialize(server_key:, base_url: nil, refresh_interval: nil, request_timeout: nil, on_error: nil, logger: nil, track_exposures: true, transport: nil, sleeper: nil, background: true) ⇒ Client
Returns a new instance of Client.
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/lucerna/gates/client.rb', line 37 def initialize(server_key:, base_url: nil, refresh_interval: nil, request_timeout: nil, on_error: nil, logger: nil, track_exposures: true, transport: nil, sleeper: nil, background: true) raise ArgumentError, "server_key is required" if server_key.nil? || server_key.to_s.empty? if server_key.start_with?("ck_client_") raise ArgumentError, "ck_client_… is a publishable browser key and cannot download gates rules — " \ "use a server key (ck_srv_… or ck_key_…) from your environment settings" end @base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "") @refresh_interval = [(refresh_interval || DEFAULT_REFRESH_INTERVAL).to_f, MIN_REFRESH_INTERVAL].max @request_timeout = (request_timeout || DEFAULT_REQUEST_TIMEOUT).to_f @reporter = Reporting.reporter(on_error, logger) @transport = transport || HTTP::NetHttpTransport.new @sleeper = sleeper || HTTP::DEFAULT_SLEEPER @headers = { "Authorization" => "Bearer #{server_key}", "Accept" => "application/json", "x-lucerna-sdk" => SDK_IDENTITY, }.freeze @background = background @runtime = nil @etag = nil @closed = false @poller = nil @pid = Process.pid @lifecycle_mutex = Mutex.new @wakeup = ConditionVariable.new @ready_mutex = Mutex.new @ready_cv = ConditionVariable.new @ready_state = :pending @ready_error = nil @exposures = if track_exposures Exposures.new( url: "#{@base_url}/sdk/v1/events", headers: @headers, transport: @transport, timeout: @request_timeout, on_error: @reporter, ) end start_poller if @background end |
Instance Method Details
#close(timeout: 2) ⇒ Object
Stops the poll and flushes queued exposures. Reads keep answering from the last-known runtime.
195 196 197 198 199 200 201 202 203 204 |
# File 'lib/lucerna/gates/client.rb', line 195 def close(timeout: 2) poller = @lifecycle_mutex.synchronize do @closed = true @wakeup.broadcast @poller end poller&.join(timeout) @exposures&.flush nil end |
#closed? ⇒ Boolean
206 207 208 |
# File 'lib/lucerna/gates/client.rb', line 206 def closed? @lifecycle_mutex.synchronize { @closed } end |
#evaluate(identity = nil) ⇒ Object
One identity's decisions over the entire runtime — for SSR or bootstrapping a client. Never records exposures.
180 181 182 183 184 185 |
# File 'lib/lucerna/gates/client.rb', line 180 def evaluate(identity = nil) safe_read({ kills: {}, flags: {}, experiments: {} }) do |runtime| user_id, traits = resolve_identity(identity) Evaluate.all(runtime, user_id: user_id, traits: traits) end end |
#experiment(key, identity = nil) ⇒ Object
The assigned variant name, or nil (not assigned / unknown key / no runtime yet). This is the only read that records an exposure — and only when a variant was assigned to a known user.
125 126 127 128 129 130 131 132 133 134 135 136 |
# File 'lib/lucerna/gates/client.rb', line 125 def experiment(key, identity = nil) safe_read(nil) do |runtime| config = runtime["experiments"][key] next nil unless config user_id, traits = resolve_identity(identity) assignment = Evaluate.experiment(config, runtime["audiences"], user_id: user_id, traits: traits) variant = assignment[:variant] record_exposure(key, assignment, user_id) if variant && user_id && !user_id.empty? variant && variant[:name] end end |
#flag(key, identity = nil) ⇒ Object
The flag's decision for this identity. Unknown key or no runtime yet: false.
110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/lucerna/gates/client.rb', line 110 def flag(key, identity = nil) safe_read(false) do |runtime| config = runtime["flags"][key] next false unless config user_id, traits = resolve_identity(identity) Evaluate.flag( config, user_id: user_id, traits: traits, audiences: runtime["audiences"], )[:on] end end |
#inspect_gate(key, identity = nil) ⇒ Object
Debug projection of one key across all three gate kinds, with the rule-by-rule trail. Never records exposures.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/lucerna/gates/client.rb', line 149 def inspect_gate(key, identity = nil) safe_read({ key: key, loaded: false }) do |runtime| user_id, traits = resolve_identity(identity) result = { key: key, loaded: true } kill = runtime["kills"][key] result[:kill] = kill unless kill.nil? if (config = runtime["flags"][key]) trace = [] decision = Evaluate.flag( config, user_id: user_id, traits: traits, trace: trace, audiences: runtime["audiences"], ) result[:flag] = { decision: decision, trace: trace } end if (config = runtime["experiments"][key]) trace = [] assignment = Evaluate.experiment( config, runtime["audiences"], user_id: user_id, traits: traits, trace: trace, ) result[:experiment] = { assignment: assignment, trace: trace } end result end end |
#runtime ⇒ Object
The last-known compiled runtime document (wire shape), or nil.
188 189 190 191 |
# File 'lib/lucerna/gates/client.rb', line 188 def runtime ensure_live! @runtime end |
#switch(key) ⇒ Object
Kill switch state — false means the guarded path IS killed. Unknown key or no runtime yet: true (not killed).
140 141 142 143 144 145 |
# File 'lib/lucerna/gates/client.rb', line 140 def switch(key) safe_read(true) do |runtime| state = runtime["kills"][key] state.nil? ? true : state end end |
#wait_until_ready(timeout: nil) ⇒ Object
Blocks until the first runtime load succeeds (true), the timeout elapses (false), or the first settle was a 401/403 (raises AuthError — a bad key should be discoverable, exactly like the node ready() rejection). Optional to call: reads are safe before ready. Network errors and 5xx keep waiting — polling retries them.
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 |
# File 'lib/lucerna/gates/client.rb', line 92 def wait_until_ready(timeout: nil) deadline = timeout && monotonic_now + timeout @ready_mutex.synchronize do loop do case @ready_state when :ready then return true when :auth_failed then raise @ready_error end remaining = deadline && deadline - monotonic_now return false if remaining && remaining <= 0 @ready_cv.wait(@ready_mutex, remaining) end end end |