shipeasy-sdk 1.3.0 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6987470d45b6349d50b279a94bc0fb356812c23f354a743c3c052433dc5759aa
4
- data.tar.gz: 34d847b481a67fd8876c5e7dfc1351dab1e832fa41f97053324cffb19e24a4c0
3
+ metadata.gz: ff3dbc0f1f6f19484edafcb75460928392fd43278036b68ed2c8179f9d21ac4d
4
+ data.tar.gz: 04d7e38426d7be6c00e504749c6a33441d88db79f70e1eae533fa9fc115b94d4
5
5
  SHA512:
6
- metadata.gz: 4a3899f6938f09b3e84f0de89f37aab45caca5ae1449157d9e6efd45274a0b224fadbbcfa54505ee84ff760a3b8309fa5545227b0f41cb2650bf83c48d7cfe1f
7
- data.tar.gz: c5ea14a6d5e53fe124bb8fbb7de06ed3930975b764964698e4c3dbc771f8e68d1766062777fe527f57b8cbd59924b94b864e8f4ed5d7574aa86b066cd9c6d896
6
+ metadata.gz: 93432ded1ed218653913a2895c0178741c4e4e06671e131722d45672f481d782791243fdc35d16347bd1462b2ea0cc2c8bdacdb7012d983a17f190c208159d9d
7
+ data.tar.gz: def56c79ef372df20b3a7325e838d8165955c39a2c8221e393546d60b61b9405ee2d5a346e2a467bc771d88c50af8806f116d1bfaac2eaed5db9f2e8f6cbd338
data/README.md CHANGED
@@ -115,6 +115,84 @@ client.init
115
115
  at_exit { client.destroy }
116
116
  ```
117
117
 
118
+ ## Default values
119
+
120
+ `get_flag` and `get_config` take an optional `default:` returned **only when the
121
+ value cannot be resolved** — never when a flag genuinely evaluates to `false`.
122
+
123
+ ```ruby
124
+ # Flag: default is returned only when the client isn't ready yet (no blob
125
+ # fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
126
+ # or outside its rollout) returns false, NOT the default.
127
+ Shipeasy.flags.get_flag("new_checkout", user, default: true)
128
+
129
+ # Config: default is returned when the config key is absent. A decode proc still
130
+ # runs on a present value.
131
+ Shipeasy.flags.get_config("button_color", default: "blue")
132
+ Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)
133
+ ```
134
+
135
+ ## Evaluation detail
136
+
137
+ `get_flag_detail(name, user)` returns the boolean **and the reason** it was
138
+ reached, as a `FlagDetail` struct (`.value`, `.reason`). `get_flag` is built on
139
+ top of it. The reason is one of the `REASON_*` constants:
140
+
141
+ | Reason | Meaning |
142
+ | ------------------ | --------------------------------------------------- |
143
+ | `OVERRIDE` | answered by a local `override_flag` (no telemetry) |
144
+ | `CLIENT_NOT_READY` | no flag blob fetched/loaded yet |
145
+ | `FLAG_NOT_FOUND` | blob present, but this gate isn't in it |
146
+ | `OFF` | gate present but disabled or killswitched |
147
+ | `RULE_MATCH` | evaluated to `true` |
148
+ | `DEFAULT` | evaluated to `false` (rollout/rule) |
149
+
150
+ ```ruby
151
+ detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
152
+ detail.value # => true / false
153
+ detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...
154
+ ```
155
+
156
+ The `gate` usage beacon fires exactly once per `get_flag_detail` call (never on
157
+ the `OVERRIDE` short-circuit).
158
+
159
+ ## Change listeners
160
+
161
+ `on_change` registers a callback fired after a background poll fetches **new**
162
+ flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
163
+ returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
164
+ no poll thread). A raising listener is isolated and logged, not propagated.
165
+
166
+ ```ruby
167
+ unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
168
+ # ... later
169
+ unsubscribe.call
170
+ ```
171
+
172
+ ## Offline snapshot
173
+
174
+ For CI, air-gapped runs, or reproducing a production decision from a captured
175
+ blob, build a **no-network** client that still runs the real evaluator against a
176
+ snapshot. The snapshot JSON holds the raw response bodies of the two SDK
177
+ endpoints:
178
+
179
+ ```json
180
+ { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
181
+ ```
182
+
183
+ ```ruby
184
+ client = Shipeasy::SDK::FlagsClient.from_file("snapshot.json")
185
+ # or, from already-parsed blobs:
186
+ client = Shipeasy::SDK::FlagsClient.from_snapshot(flags: flags_body, experiments: exps_body)
187
+
188
+ client.get_flag("new_checkout", user) # real evaluation, no network
189
+ client.get_experiment("checkout_cta", user, {})
190
+ ```
191
+
192
+ `init` / `init_once` / `track` are no-ops and telemetry is off (it reuses the
193
+ `for_testing` plumbing). Local `override_*` setters still apply on top of the
194
+ snapshot.
195
+
118
196
  ## Evaluation details
119
197
 
120
198
  - **Gates** — rules matched in order; rollout bucket =
@@ -128,6 +206,66 @@ at_exit { client.destroy }
128
206
  - **Poll interval** — defaults to 30 s; overridden by the
129
207
  `X-Poll-Interval` header from the flags endpoint.
130
208
 
209
+ ## Testing
210
+
211
+ For unit/integration tests you want a client that does **zero network** and
212
+ returns exactly the values you seed — no api_key, no fetch, no poll thread, no
213
+ telemetry, no metric ingestion. Build one with `FlagsClient.for_testing` and
214
+ seed each entity with the `override_*` setters (Statsig-style local overrides).
215
+ An override always wins over the fetched blob, so the getters answer
216
+ deterministically:
217
+
218
+ ```ruby
219
+ require "shipeasy-sdk"
220
+
221
+ client = Shipeasy::SDK::FlagsClient.for_testing
222
+ # init / init_once are no-ops here — nothing is ever fetched.
223
+
224
+ # Flags (boolean)
225
+ client.override_flag("new_checkout", true)
226
+ client.get_flag("new_checkout", { user_id: "u_1" }) # => true
227
+
228
+ # Configs (any value; an optional decode proc still runs)
229
+ client.override_config("button_color", "blue")
230
+ client.get_config("button_color") # => "blue"
231
+ client.override_config("limits", { "max" => 10 })
232
+ client.get_config("limits", ->(v) { v["max"] }) # => 10
233
+
234
+ # Experiments — returns an in-experiment Eval::ExperimentResult
235
+ client.override_experiment("checkout_cta", "treatment", { label: "Buy now" })
236
+ r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
237
+ r.in_experiment # => true
238
+ r.group # => "treatment"
239
+ r.params # => { label: "Buy now" }
240
+
241
+ # track is a no-op (no thread, no network) — assert call counts without stubbing.
242
+ client.track("u_1", "checkout_completed", { revenue: 49.99 }) # => nil
243
+
244
+ # Reset between examples
245
+ client.clear_overrides
246
+ ```
247
+
248
+ The same `override_flag` / `override_config` / `override_experiment` /
249
+ `clear_overrides` setters also work on a **normal** live client (built with
250
+ `FlagsClient.new(...)`), so you can pin one value in local development while the
251
+ rest comes from the fetched blob.
252
+
253
+ ### Rails singleton
254
+
255
+ `Shipeasy.flags` is a process-wide singleton that fetches over the network, so
256
+ in tests prefer a `for_testing` client. If a code path reaches through
257
+ `Shipeasy.flags` directly, stub the singleton to the test client in your test
258
+ setup:
259
+
260
+ ```ruby
261
+ # RSpec
262
+ before do
263
+ test_client = Shipeasy::SDK::FlagsClient.for_testing
264
+ test_client.override_flag("new_checkout", true)
265
+ allow(Shipeasy).to receive(:flags).and_return(test_client)
266
+ end
267
+ ```
268
+
131
269
  ## Configuration
132
270
 
133
271
  | Parameter | Default | Description |
@@ -57,6 +57,19 @@ module Shipeasy
57
57
  end
58
58
  end
59
59
 
60
+ # Pick the bucketing identifier. When bucket_by is set and the user
61
+ # carries that attribute as a non-empty string (or any number, stringified),
62
+ # bucket on it — so a whole company/org lands on one variant. Otherwise fall
63
+ # back to user_id, then anonymous_id. Mirrors core's pickIdentifier.
64
+ def self.pick_identifier(user, bucket_by)
65
+ if bucket_by && !bucket_by.to_s.empty?
66
+ v = user[bucket_by] || user[bucket_by.to_sym]
67
+ return v if v.is_a?(String) && !v.empty?
68
+ return v.to_s if v.is_a?(Numeric)
69
+ end
70
+ user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
71
+ end
72
+
60
73
  def self.eval_gate(gate, user)
61
74
  return false if enabled?(gate["killswitch"])
62
75
  return false unless enabled?(gate["enabled"])
@@ -79,7 +92,14 @@ module Shipeasy
79
92
 
80
93
  ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
81
94
 
82
- def self.eval_experiment(exp, flags_blob, exps_blob, user)
95
+ # exp_name + sticky_store are optional so existing callers stay deterministic.
96
+ # When a sticky_store is passed, an enrolled unit whose stored salt prefix
97
+ # still matches skips the allocation gate (so a shrinking allocation keeps
98
+ # it in) and returns the stored group without re-running the pick. A fresh
99
+ # pick is persisted via store.set; a salt mismatch / missing stored group
100
+ # falls through to re-bucket + overwrite. Mirrors the TS reference
101
+ # (doc 20 §2). exp_name is the key under which the entry is stored.
102
+ def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
83
103
  not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
84
104
 
85
105
  return not_in unless exp && exp["status"] == "running"
@@ -90,7 +110,8 @@ module Shipeasy
90
110
  return not_in unless gate && eval_gate(gate, user)
91
111
  end
92
112
 
93
- uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
113
+ bucket_by = exp["bucketBy"] || exp[:bucketBy]
114
+ uid = pick_identifier(user, bucket_by)
94
115
  return not_in unless uid
95
116
 
96
117
  universe_name = exp["universe"]
@@ -103,14 +124,28 @@ module Shipeasy
103
124
 
104
125
  salt = exp["salt"]
105
126
  allocation_pct = exp["allocationPct"] || 0
127
+ groups = exp["groups"] || []
128
+ salt8 = (salt || "")[0, 8]
129
+
130
+ # Sticky short-circuit: an enrolled unit whose stored salt prefix still
131
+ # matches skips allocation and returns the stored group. If the stored
132
+ # group no longer exists, fall through to re-bucket + overwrite.
133
+ if sticky_store && exp_name
134
+ entry = (sticky_store.get(uid) || {})[exp_name]
135
+ if entry && entry["s"] == salt8
136
+ g = groups.find { |x| x["name"] == entry["g"] }
137
+ return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"]) if g
138
+ end
139
+ end
140
+
106
141
  return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
107
142
 
108
143
  group_hash = murmur3("#{salt}:group:#{uid}") % 10000
109
144
  cumulative = 0
110
- groups = exp["groups"] || []
111
145
  groups.each_with_index do |g, i|
112
146
  cumulative += g["weight"]
113
147
  if group_hash < cumulative || i == groups.length - 1
148
+ sticky_store.set(uid, exp_name, { "g" => g["name"], "s" => salt8 }) if sticky_store && exp_name
114
149
  return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
115
150
  end
116
151
  end
@@ -5,15 +5,29 @@ require "thread"
5
5
  require_relative "eval"
6
6
  require_relative "telemetry"
7
7
  require_relative "anon_id"
8
+ require_relative "sticky_store"
8
9
 
9
10
  module Shipeasy
10
11
  module SDK
11
12
  class FlagsClient
12
13
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
13
14
 
14
- def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil)
15
+ def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil)
15
16
  @api_key = api_key
16
17
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
18
+ # Attribute names usable for targeting but stripped from every outbound
19
+ # /collect payload (LD/Statsig privateAttributes). The server evaluates
20
+ # locally so private attrs never leave for evaluation; the only egress is
21
+ # track(), where the listed keys are dropped from the props bag.
22
+ @private_attributes = (private_attributes || []).map(&:to_s)
23
+ # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
24
+ # Threaded into get_experiment so an enrolled unit locks to its first
25
+ # assigned variant. Built-in: InMemoryStickyStore.
26
+ @sticky_store = sticky_store
27
+ # Test mode: no network, ever. init/init_once/track become no-ops and
28
+ # evaluation answers come purely from local overrides. Built via the
29
+ # FlagsClient.for_testing factory; see clear_overrides / override_*.
30
+ @test_mode = test_mode
17
31
  # Per-evaluation usage telemetry. ON by default; pass
18
32
  # disable_telemetry: true to opt out. See telemetry.rb.
19
33
  @telemetry = Telemetry.new(
@@ -31,45 +45,203 @@ module Shipeasy
31
45
  @mutex = Mutex.new
32
46
  @timer = nil
33
47
  @initialized = false
48
+ # Statsig-style local overrides. Keyed by resource name; an override,
49
+ # when present, short-circuits the corresponding getter. Usable on any
50
+ # client (test or live) for deterministic tests / local development.
51
+ @flag_overrides = {}
52
+ @config_overrides = {}
53
+ @exp_overrides = {}
54
+ # Change listeners — fired after a background poll returns NEW data
55
+ # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
56
+ # @mutex; see on_change / notify_change.
57
+ @change_listeners = []
58
+ end
59
+
60
+ # Build a no-network, immediately-usable client for tests. Telemetry is
61
+ # disabled, init/init_once/track are no-ops (never fetch), and no api_key
62
+ # is required. Seed it with override_flag / override_config /
63
+ # override_experiment, then call the normal getters.
64
+ def self.for_testing(env: "prod")
65
+ new(
66
+ api_key: "test",
67
+ env: env,
68
+ disable_telemetry: true,
69
+ test_mode: true,
70
+ )
71
+ end
72
+
73
+ # Build an offline client from a JSON snapshot file. The file holds the
74
+ # raw response bodies of the two SDK endpoints under "flags" and
75
+ # "experiments" keys:
76
+ #
77
+ # { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
78
+ #
79
+ # The returned client does ZERO network (reuses test_mode plumbing:
80
+ # init/init_once/track are no-ops, telemetry off) but, unlike a bare
81
+ # for_testing client, runs the REAL evaluator against the loaded blobs.
82
+ # Local overrides still apply on top. Handy for CI, air-gapped runs, and
83
+ # reproducing a production decision from a captured blob.
84
+ def self.from_file(path, env: "prod")
85
+ data = JSON.parse(File.read(path))
86
+ from_snapshot(flags: data["flags"], experiments: data["experiments"], env: env)
87
+ end
88
+
89
+ # Build an offline client directly from already-parsed blobs (same shape
90
+ # as the /sdk/flags and /sdk/experiments response bodies). See from_file.
91
+ def self.from_snapshot(flags: nil, experiments: nil, env: "prod")
92
+ client = for_testing(env: env)
93
+ client.send(:load_snapshot, flags, experiments)
94
+ client
34
95
  end
35
96
 
36
97
  def init
98
+ return if @test_mode
37
99
  fetch_all
38
100
  @initialized = true
39
101
  start_poll
40
102
  end
41
103
 
42
104
  def init_once
105
+ return if @test_mode
43
106
  return if @initialized
44
107
  fetch_all
45
108
  @initialized = true
46
109
  end
47
110
 
111
+ # --- Local overrides -------------------------------------------------
112
+ # An override wins over the fetched blob in the matching getter. Setters
113
+ # are mutex-guarded so they're safe to call alongside background polling
114
+ # on a live client.
115
+
116
+ def override_flag(name, value)
117
+ @mutex.synchronize { @flag_overrides[name.to_s] = (value ? true : false) }
118
+ self
119
+ end
120
+
121
+ def override_config(name, value)
122
+ @mutex.synchronize { @config_overrides[name.to_s] = value }
123
+ self
124
+ end
125
+
126
+ def override_experiment(name, group, params)
127
+ @mutex.synchronize do
128
+ @exp_overrides[name.to_s] = { group: group, params: params }
129
+ end
130
+ self
131
+ end
132
+
133
+ def clear_overrides
134
+ @mutex.synchronize do
135
+ @flag_overrides.clear
136
+ @config_overrides.clear
137
+ @exp_overrides.clear
138
+ end
139
+ self
140
+ end
141
+
142
+ # Register a listener fired after a background poll fetches NEW flag/config
143
+ # data (HTTP 200, not 304). Accepts either a block or any callable (an
144
+ # object responding to #call). Returns an unsubscribe proc — call it to
145
+ # remove the listener. Never fires in test/offline mode (no poll thread).
146
+ def on_change(callable = nil, &block)
147
+ listener = callable || block
148
+ raise ArgumentError, "on_change requires a block or callable" unless listener.respond_to?(:call)
149
+ @mutex.synchronize { @change_listeners << listener }
150
+ proc { @mutex.synchronize { @change_listeners.delete(listener) } }
151
+ end
152
+
48
153
  def destroy
49
154
  @timer&.kill
50
155
  @timer = nil
51
156
  end
52
157
 
53
- def get_flag(name, user)
158
+ # Flag evaluation with the reason the value was reached. :value is the
159
+ # boolean result; :reason is one of the REASON_* constants below.
160
+ FlagDetail = Struct.new(:value, :reason, keyword_init: true)
161
+
162
+ # Reason constants for FlagDetail#reason / get_flag_detail.
163
+ REASON_CLIENT_NOT_READY = "CLIENT_NOT_READY" # no blob fetched/loaded yet
164
+ REASON_FLAG_NOT_FOUND = "FLAG_NOT_FOUND" # blob present, gate absent
165
+ REASON_OFF = "OFF" # gate present but disabled/killed
166
+ REASON_OVERRIDE = "OVERRIDE" # answered by a local override
167
+ REASON_RULE_MATCH = "RULE_MATCH" # evaluated true
168
+ REASON_DEFAULT = "DEFAULT" # evaluated false (rollout/rule)
169
+
170
+ # Evaluate a flag and return why. Telemetry ("gate" beacon) is emitted
171
+ # exactly once here (steps 2–5), never on the OVERRIDE short-circuit.
172
+ def get_flag_detail(name, user)
173
+ key = name.to_s
174
+
175
+ # 1. Override short-circuits before any telemetry (mirrors get_config).
176
+ override = @mutex.synchronize { @flag_overrides[key] if @flag_overrides.key?(key) }
177
+ return FlagDetail.new(value: override, reason: REASON_OVERRIDE) unless override.nil?
178
+
54
179
  @telemetry.emit("gate", name)
55
- gate = @mutex.synchronize { @flags_blob&.dig("gates", name) }
56
- return false unless gate
57
- Eval.eval_gate(gate, with_anon_id(user))
180
+
181
+ flags_blob, gate = @mutex.synchronize { [@flags_blob, @flags_blob&.dig("gates", name)] }
182
+
183
+ # 2. Not initialized — no blob fetched or loaded yet.
184
+ return FlagDetail.new(value: false, reason: REASON_CLIENT_NOT_READY) if flags_blob.nil?
185
+
186
+ # 3. Blob present but this gate isn't in it.
187
+ return FlagDetail.new(value: false, reason: REASON_FLAG_NOT_FOUND) unless gate
188
+
189
+ # 4. Gate present but disabled (or killswitched) — eval_gate would also
190
+ # return false here, but the reason is OFF, not a rollout DEFAULT.
191
+ if Eval.enabled?(gate["killswitch"]) || !Eval.enabled?(gate["enabled"])
192
+ return FlagDetail.new(value: false, reason: REASON_OFF)
193
+ end
194
+
195
+ # 5. Run the canonical evaluator; reason follows the boolean result.
196
+ result = Eval.eval_gate(gate, with_anon_id(user))
197
+ FlagDetail.new(value: result, reason: result ? REASON_RULE_MATCH : REASON_DEFAULT)
198
+ end
199
+
200
+ def get_flag(name, user, default: false)
201
+ detail = get_flag_detail(name, user)
202
+ if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
203
+ default
204
+ else
205
+ detail.value
206
+ end
58
207
  end
59
208
 
60
- def get_config(name, decode = nil)
209
+ def get_config(name, decode = nil, default: nil)
210
+ key = name.to_s
211
+ has_override, override = @mutex.synchronize do
212
+ [@config_overrides.key?(key), @config_overrides[key]]
213
+ end
214
+ if has_override
215
+ return decode ? decode.call(override) : override
216
+ end
217
+
61
218
  @telemetry.emit("config", name)
62
219
  entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
63
- return nil unless entry
220
+ return default unless entry
64
221
  value = entry["value"]
65
222
  decode ? decode.call(value) : value
66
223
  end
67
224
 
68
225
  def get_experiment(name, user, default_params, decode = nil)
226
+ key = name.to_s
227
+ override = @mutex.synchronize { @exp_overrides[key] }
228
+ if override
229
+ params = override[:params]
230
+ params = decode.call(params) if decode
231
+ return Eval::ExperimentResult.new(
232
+ in_experiment: true,
233
+ group: override[:group],
234
+ params: params,
235
+ )
236
+ end
237
+
69
238
  @telemetry.emit("experiment", name)
70
239
  flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
71
240
  exp = exps_blob&.dig("experiments", name)
72
- result = Eval.eval_experiment(exp, flags_blob, exps_blob, with_anon_id(user))
241
+ result = Eval.eval_experiment(
242
+ exp, flags_blob, exps_blob, with_anon_id(user),
243
+ exp_name: name.to_s, sticky_store: @sticky_store,
244
+ )
73
245
  result.params ||= default_params
74
246
 
75
247
  if result.in_experiment && decode
@@ -89,13 +261,17 @@ module Shipeasy
89
261
  end
90
262
 
91
263
  def track(user_id, event_name, props = {})
264
+ return if @test_mode
265
+
266
+ safe_props = strip_private(props)
267
+
92
268
  payload = JSON.generate({
93
269
  events: [{
94
270
  type: "metric",
95
271
  event_name: event_name,
96
272
  user_id: user_id.to_s,
97
273
  ts: (Time.now.to_f * 1000).to_i,
98
- **(props.empty? ? {} : { properties: props }),
274
+ **(safe_props.empty? ? {} : { properties: safe_props }),
99
275
  }],
100
276
  })
101
277
 
@@ -106,8 +282,72 @@ module Shipeasy
106
282
  end
107
283
  end
108
284
 
285
+ # Emit an exposure event for an experiment at the server-side decision
286
+ # point (parity with the browser's auto-exposure). The server is stateless
287
+ # and never auto-logs, so call this when you actually present the
288
+ # treatment. Re-evaluates the experiment for the user (a bare user_id
289
+ # string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
290
+ # exposure to /collect. No-op in test mode or when the user isn't enrolled.
291
+ def log_exposure(user_or_user_id, experiment_name)
292
+ return if @test_mode
293
+
294
+ user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
295
+ result = get_experiment(experiment_name, user, {})
296
+ return unless result.in_experiment
297
+
298
+ u = user.transform_keys(&:to_s)
299
+ payload = JSON.generate({
300
+ events: [{
301
+ type: "exposure",
302
+ experiment: experiment_name.to_s,
303
+ group: result.group,
304
+ user_id: (u["user_id"] || u["anonymous_id"]).to_s,
305
+ ts: (Time.now.to_f * 1000).to_i,
306
+ }],
307
+ })
308
+
309
+ Thread.new do
310
+ post("/collect", payload)
311
+ rescue => e
312
+ warn "[shipeasy] log_exposure failed: #{e.message}"
313
+ end
314
+ end
315
+
109
316
  private
110
317
 
318
+ # Drop caller-marked private attributes from an outbound props bag. Handles
319
+ # both string and symbol keys against the stringified private list.
320
+ def strip_private(props)
321
+ return props if props.nil? || props.empty? || @private_attributes.empty?
322
+ props.reject { |k, _| @private_attributes.include?(k.to_s) }
323
+ end
324
+
325
+ # Load a parsed snapshot into the local blobs and mark the client ready,
326
+ # without any network. Used by from_snapshot / from_file on a test_mode
327
+ # client so the real evaluator runs against captured data.
328
+ def load_snapshot(flags, experiments)
329
+ @mutex.synchronize do
330
+ @flags_blob = flags
331
+ @exps_blob = experiments
332
+ end
333
+ @initialized = true
334
+ self
335
+ end
336
+
337
+ # Fire each change listener, snapshotting the array under the mutex so a
338
+ # listener that unsubscribes mid-callback doesn't mutate the list we're
339
+ # iterating. Listener errors are isolated (warn, never propagate).
340
+ def notify_change
341
+ listeners = @mutex.synchronize { @change_listeners.dup }
342
+ listeners.each do |listener|
343
+ begin
344
+ listener.call
345
+ rescue => e
346
+ warn "[shipeasy] on_change listener raised: #{e.message}"
347
+ end
348
+ end
349
+ end
350
+
111
351
  # Normalise the user hash to string keys and, when the caller passed no
112
352
  # explicit unit, default anonymous_id to the request's __se_anon_id (set by
113
353
  # RackMiddleware). Lets `get_flag("x", {})` bucket anonymous traffic with
@@ -162,6 +402,8 @@ module Shipeasy
162
402
  @flags_etag = etag if etag
163
403
  @flags_blob = blob
164
404
  end
405
+ # New data arrived (200, not the 304 returned above) — notify listeners.
406
+ notify_change
165
407
  interval
166
408
  end
167
409
 
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OpenFeature provider for Shipeasy (server paradigm).
4
+ #
5
+ # Lets apps standardized on the CNCF OpenFeature API plug Shipeasy in as the
6
+ # backing provider. This file is intentionally NOT required by the main
7
+ # `shipeasy-sdk` entrypoint — `openfeature-sdk` is an optional development
8
+ # dependency, so the provider is loaded lazily and the gem is required from
9
+ # inside this file. Require it explicitly when you want the provider:
10
+ #
11
+ # require "open_feature/sdk"
12
+ # require "shipeasy/sdk/openfeature"
13
+ #
14
+ # client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
15
+ # client.init
16
+ #
17
+ # OpenFeature::SDK.configure do |config|
18
+ # config.set_provider(Shipeasy::OpenFeature::Provider.new(client))
19
+ # end
20
+ #
21
+ # of = OpenFeature::SDK.build_client
22
+ # on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
23
+ # evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
24
+ #
25
+ # Pure adapter over `FlagsClient` — no change to evaluation. Boolean values map
26
+ # onto gates (`get_flag_detail`); string/number/integer/float/object map onto
27
+ # dynamic configs (`get_config`).
28
+
29
+ # `openfeature-sdk` (module `OpenFeature::SDK::Provider`) is an optional dep.
30
+ # Require it lazily so the main SDK never pulls it in; surface a clear error if
31
+ # the consumer forgot to add it.
32
+ begin
33
+ require "open_feature/sdk"
34
+ rescue LoadError => e
35
+ raise LoadError, "shipeasy/sdk/openfeature requires the `openfeature-sdk` gem " \
36
+ "(module OpenFeature::SDK::Provider). Add it to your Gemfile: " \
37
+ "gem \"openfeature-sdk\". (#{e.message})"
38
+ end
39
+
40
+ require_relative "flags_client"
41
+
42
+ module Shipeasy
43
+ module OpenFeature
44
+ # Shipeasy OpenFeature provider (server paradigm). Wraps a
45
+ # `Shipeasy::SDK::FlagsClient`; evaluation is local against the cached blob,
46
+ # so resolution is effectively synchronous.
47
+ class Provider
48
+ OF = ::OpenFeature::SDK::Provider
49
+
50
+ # Shipeasy `FlagDetail#reason` → [OpenFeature reason, optional error_code].
51
+ # Per the cross-SDK contract (doc 20):
52
+ # RULE_MATCH → TARGETING_MATCH
53
+ # DEFAULT → DEFAULT
54
+ # OFF → DISABLED
55
+ # OVERRIDE → STATIC
56
+ # FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
57
+ # CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
58
+ REASON_MAP = {
59
+ Shipeasy::SDK::FlagsClient::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
60
+ Shipeasy::SDK::FlagsClient::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
61
+ Shipeasy::SDK::FlagsClient::REASON_OFF => [OF::Reason::DISABLED, nil],
62
+ Shipeasy::SDK::FlagsClient::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
63
+ Shipeasy::SDK::FlagsClient::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
64
+ Shipeasy::SDK::FlagsClient::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
65
+ }.freeze
66
+
67
+ attr_reader :metadata
68
+
69
+ def initialize(client)
70
+ @client = client
71
+ @metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
72
+ end
73
+
74
+ # OpenFeature lifecycle (optional but supported): fetch the blob once and
75
+ # tear down the poll thread on shutdown.
76
+ def init(_evaluation_context = nil)
77
+ @client.init_once
78
+ end
79
+
80
+ def shutdown
81
+ @client.destroy
82
+ end
83
+
84
+ # --- Boolean → gate ------------------------------------------------------
85
+
86
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
87
+ user = to_user(evaluation_context)
88
+ detail = @client.get_flag_detail(flag_key, user)
89
+ of_reason, error_code = REASON_MAP.fetch(detail.reason, [OF::Reason::UNKNOWN, nil])
90
+
91
+ if error_code
92
+ OF::ResolutionDetails.new(value: default_value, reason: of_reason, error_code: error_code)
93
+ else
94
+ OF::ResolutionDetails.new(value: detail.value, reason: of_reason)
95
+ end
96
+ rescue => e
97
+ OF::ResolutionDetails.new(
98
+ value: default_value, reason: OF::Reason::ERROR,
99
+ error_code: OF::ErrorCode::GENERAL, error_message: e.message,
100
+ )
101
+ end
102
+
103
+ # --- String / number / integer / float / object → dynamic config --------
104
+
105
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
106
+ resolve_config(flag_key, default_value) { |v| v.is_a?(String) }
107
+ end
108
+
109
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
110
+ resolve_config(flag_key, default_value) { |v| numeric?(v) }
111
+ end
112
+
113
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
114
+ resolve_config(flag_key, default_value) { |v| v.is_a?(Integer) }
115
+ end
116
+
117
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
118
+ resolve_config(flag_key, default_value) { |v| numeric?(v) }
119
+ end
120
+
121
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
122
+ resolve_config(flag_key, default_value) { |v| v.is_a?(Hash) || v.is_a?(Array) }
123
+ end
124
+
125
+ # OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
126
+ def track(tracking_event_name, evaluation_context: nil, details: {})
127
+ ctx = normalize_context(evaluation_context)
128
+ user_id = ctx["targeting_key"] || ctx["user_id"]
129
+ return if user_id.nil? || user_id.to_s.empty?
130
+
131
+ props = details.is_a?(Hash) ? details : {}
132
+ @client.track(user_id, tracking_event_name, props)
133
+ end
134
+
135
+ private
136
+
137
+ # A sentinel distinct from any legitimate config value so we can tell an
138
+ # absent key (→ DEFAULT) from a present-but-nil value.
139
+ ABSENT = Object.new
140
+ private_constant :ABSENT
141
+
142
+ # Resolve a dynamic config and type-check it. Absent key → DEFAULT;
143
+ # present but failing the type predicate → TYPE_MISMATCH; otherwise
144
+ # TARGETING_MATCH with the value. Both return the default for the value.
145
+ def resolve_config(flag_key, default_value)
146
+ raw = @client.get_config(flag_key, nil, default: ABSENT)
147
+
148
+ if raw.equal?(ABSENT)
149
+ return OF::ResolutionDetails.new(value: default_value, reason: OF::Reason::DEFAULT)
150
+ end
151
+
152
+ unless yield(raw)
153
+ return OF::ResolutionDetails.new(
154
+ value: default_value, reason: OF::Reason::ERROR,
155
+ error_code: OF::ErrorCode::TYPE_MISMATCH,
156
+ error_message: "config value #{raw.inspect} does not match the requested type",
157
+ )
158
+ end
159
+
160
+ OF::ResolutionDetails.new(value: raw, reason: OF::Reason::TARGETING_MATCH)
161
+ rescue => e
162
+ OF::ResolutionDetails.new(
163
+ value: default_value, reason: OF::Reason::ERROR,
164
+ error_code: OF::ErrorCode::GENERAL, error_message: e.message,
165
+ )
166
+ end
167
+
168
+ def numeric?(value)
169
+ # Booleans are Integers' cousins in some langs but not Ruby; exclude
170
+ # them explicitly so `true` never satisfies a number/float request.
171
+ return false if value == true || value == false
172
+
173
+ value.is_a?(Numeric)
174
+ end
175
+
176
+ # Build a Shipeasy user hash from an OpenFeature EvaluationContext:
177
+ # `targeting_key` → `user_id`; every other field carried through verbatim
178
+ # for targeting. Accepts a real EvaluationContext, a plain Hash, or nil.
179
+ def to_user(evaluation_context)
180
+ ctx = normalize_context(evaluation_context)
181
+ targeting_key = ctx["targeting_key"]
182
+ rest = ctx.reject { |k, _| k == "targeting_key" }
183
+ user = rest
184
+ if targeting_key.is_a?(String) && !targeting_key.empty?
185
+ user = user.merge("user_id" => targeting_key)
186
+ end
187
+ user
188
+ end
189
+
190
+ # Coerce any of {EvaluationContext, Hash, nil} into a string-keyed Hash.
191
+ def normalize_context(evaluation_context)
192
+ return {} if evaluation_context.nil?
193
+
194
+ if evaluation_context.respond_to?(:fields)
195
+ evaluation_context.fields.transform_keys(&:to_s)
196
+ elsif evaluation_context.is_a?(Hash)
197
+ evaluation_context.transform_keys(&:to_s)
198
+ else
199
+ {}
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,39 @@
1
+ require "thread"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Pluggable sticky-bucketing store for the server (doc 20 §2). Duck-typed:
6
+ # any object responding to the two methods below works.
7
+ #
8
+ # get(unit) -> { exp_name => { "g" => group, "s" => salt8 } } or nil
9
+ # set(unit, exp_name, entry) # entry = { "g" => group, "s" => salt8 }
10
+ #
11
+ # Keyed by the bucketing unit (pick_identifier-resolved id). When threaded
12
+ # into experiment eval, an enrolled unit locks to its first-assigned variant
13
+ # — changing allocation % or weights won't re-bucket it; changing the
14
+ # experiment salt is the reshuffle lever. Absent ⇒ deterministic behavior.
15
+ class InMemoryStickyStore
16
+ # Optionally seed with { unit => { exp => { "g"=>.., "s"=>.. } } }.
17
+ def initialize(seed = nil)
18
+ @mutex = Mutex.new
19
+ @data = {}
20
+ if seed
21
+ seed.each { |unit, exps| @data[unit.to_s] = exps.dup }
22
+ end
23
+ end
24
+
25
+ # Return this unit's per-experiment assignments, or nil if none.
26
+ def get(unit)
27
+ @mutex.synchronize { @data[unit.to_s] }
28
+ end
29
+
30
+ # Persist one assignment for (unit, exp).
31
+ def set(unit, exp, entry)
32
+ @mutex.synchronize do
33
+ (@data[unit.to_s] ||= {})[exp] = entry
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.3.0"
3
+ VERSION = "1.5.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -2,6 +2,7 @@ require_relative "shipeasy/sdk/version"
2
2
  require_relative "shipeasy/config"
3
3
  require_relative "shipeasy/sdk/murmur3"
4
4
  require_relative "shipeasy/sdk/eval"
5
+ require_relative "shipeasy/sdk/sticky_store"
5
6
  require_relative "shipeasy/sdk/flags_client"
6
7
  require_relative "shipeasy/sdk/anon_id"
7
8
  require_relative "shipeasy/sdk/rack_middleware"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-13 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -73,8 +73,10 @@ files:
73
73
  - lib/shipeasy/sdk/eval.rb
74
74
  - lib/shipeasy/sdk/flags_client.rb
75
75
  - lib/shipeasy/sdk/murmur3.rb
76
+ - lib/shipeasy/sdk/openfeature.rb
76
77
  - lib/shipeasy/sdk/rack_middleware.rb
77
78
  - lib/shipeasy/sdk/railtie.rb
79
+ - lib/shipeasy/sdk/sticky_store.rb
78
80
  - lib/shipeasy/sdk/telemetry.rb
79
81
  - lib/shipeasy/sdk/version.rb
80
82
  homepage: https://github.com/shipeasy-ai/sdk-ruby