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 +7 -0
- data/README.md +88 -0
- data/lib/lucerna/config.rb +37 -0
- data/lib/lucerna/errors.rb +19 -0
- data/lib/lucerna/gates/client.rb +355 -0
- data/lib/lucerna/gates/evaluate.rb +309 -0
- data/lib/lucerna/gates/exposures.rb +105 -0
- data/lib/lucerna/gates/hashing.rb +71 -0
- data/lib/lucerna/gates/runtime.rb +23 -0
- data/lib/lucerna/http.rb +77 -0
- data/lib/lucerna/identity/client.rb +159 -0
- data/lib/lucerna/identity.rb +72 -0
- data/lib/lucerna/railtie.rb +22 -0
- data/lib/lucerna/reporting.rb +23 -0
- data/lib/lucerna/traits.rb +32 -0
- data/lib/lucerna/version.rb +8 -0
- data/lib/lucerna.rb +136 -0
- metadata +57 -0
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
|