Class: Lucerna::Gates::Client

Inherits:
Object
  • Object
show all
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

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.

Raises:

  • (ArgumentError)


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

Returns:

  • (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

#runtimeObject

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