shipeasy-sdk 1.3.0 → 1.4.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: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
4
+ data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
5
5
  SHA512:
6
- metadata.gz: 4a3899f6938f09b3e84f0de89f37aab45caca5ae1449157d9e6efd45274a0b224fadbbcfa54505ee84ff760a3b8309fa5545227b0f41cb2650bf83c48d7cfe1f
7
- data.tar.gz: c5ea14a6d5e53fe124bb8fbb7de06ed3930975b764964698e4c3dbc771f8e68d1766062777fe527f57b8cbd59924b94b864e8f4ed5d7574aa86b066cd9c6d896
6
+ metadata.gz: cff2bbf433472f1e2be585ccbf5deb12d420e29130bb8f09961f2b277135a0c1726c1318a0f1b7b57fce511b114b6fcde982cc0735b884bb946de46016638c52
7
+ data.tar.gz: 1ff69897f3b0501e6c4e41fa5eb386017fc4d714a0a5423871ec867b969bea2d1097fda01fb3902b1a57bc37a0490aaf433169e9d829f11739f3ac63cbcabb56
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 |
@@ -11,9 +11,13 @@ module Shipeasy
11
11
  class FlagsClient
12
12
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
13
13
 
14
- def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil)
14
+ def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false)
15
15
  @api_key = api_key
16
16
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
17
+ # Test mode: no network, ever. init/init_once/track become no-ops and
18
+ # evaluation answers come purely from local overrides. Built via the
19
+ # FlagsClient.for_testing factory; see clear_overrides / override_*.
20
+ @test_mode = test_mode
17
21
  # Per-evaluation usage telemetry. ON by default; pass
18
22
  # disable_telemetry: true to opt out. See telemetry.rb.
19
23
  @telemetry = Telemetry.new(
@@ -31,41 +35,196 @@ module Shipeasy
31
35
  @mutex = Mutex.new
32
36
  @timer = nil
33
37
  @initialized = false
38
+ # Statsig-style local overrides. Keyed by resource name; an override,
39
+ # when present, short-circuits the corresponding getter. Usable on any
40
+ # client (test or live) for deterministic tests / local development.
41
+ @flag_overrides = {}
42
+ @config_overrides = {}
43
+ @exp_overrides = {}
44
+ # Change listeners — fired after a background poll returns NEW data
45
+ # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
46
+ # @mutex; see on_change / notify_change.
47
+ @change_listeners = []
48
+ end
49
+
50
+ # Build a no-network, immediately-usable client for tests. Telemetry is
51
+ # disabled, init/init_once/track are no-ops (never fetch), and no api_key
52
+ # is required. Seed it with override_flag / override_config /
53
+ # override_experiment, then call the normal getters.
54
+ def self.for_testing(env: "prod")
55
+ new(
56
+ api_key: "test",
57
+ env: env,
58
+ disable_telemetry: true,
59
+ test_mode: true,
60
+ )
61
+ end
62
+
63
+ # Build an offline client from a JSON snapshot file. The file holds the
64
+ # raw response bodies of the two SDK endpoints under "flags" and
65
+ # "experiments" keys:
66
+ #
67
+ # { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
68
+ #
69
+ # The returned client does ZERO network (reuses test_mode plumbing:
70
+ # init/init_once/track are no-ops, telemetry off) but, unlike a bare
71
+ # for_testing client, runs the REAL evaluator against the loaded blobs.
72
+ # Local overrides still apply on top. Handy for CI, air-gapped runs, and
73
+ # reproducing a production decision from a captured blob.
74
+ def self.from_file(path, env: "prod")
75
+ data = JSON.parse(File.read(path))
76
+ from_snapshot(flags: data["flags"], experiments: data["experiments"], env: env)
77
+ end
78
+
79
+ # Build an offline client directly from already-parsed blobs (same shape
80
+ # as the /sdk/flags and /sdk/experiments response bodies). See from_file.
81
+ def self.from_snapshot(flags: nil, experiments: nil, env: "prod")
82
+ client = for_testing(env: env)
83
+ client.send(:load_snapshot, flags, experiments)
84
+ client
34
85
  end
35
86
 
36
87
  def init
88
+ return if @test_mode
37
89
  fetch_all
38
90
  @initialized = true
39
91
  start_poll
40
92
  end
41
93
 
42
94
  def init_once
95
+ return if @test_mode
43
96
  return if @initialized
44
97
  fetch_all
45
98
  @initialized = true
46
99
  end
47
100
 
101
+ # --- Local overrides -------------------------------------------------
102
+ # An override wins over the fetched blob in the matching getter. Setters
103
+ # are mutex-guarded so they're safe to call alongside background polling
104
+ # on a live client.
105
+
106
+ def override_flag(name, value)
107
+ @mutex.synchronize { @flag_overrides[name.to_s] = (value ? true : false) }
108
+ self
109
+ end
110
+
111
+ def override_config(name, value)
112
+ @mutex.synchronize { @config_overrides[name.to_s] = value }
113
+ self
114
+ end
115
+
116
+ def override_experiment(name, group, params)
117
+ @mutex.synchronize do
118
+ @exp_overrides[name.to_s] = { group: group, params: params }
119
+ end
120
+ self
121
+ end
122
+
123
+ def clear_overrides
124
+ @mutex.synchronize do
125
+ @flag_overrides.clear
126
+ @config_overrides.clear
127
+ @exp_overrides.clear
128
+ end
129
+ self
130
+ end
131
+
132
+ # Register a listener fired after a background poll fetches NEW flag/config
133
+ # data (HTTP 200, not 304). Accepts either a block or any callable (an
134
+ # object responding to #call). Returns an unsubscribe proc — call it to
135
+ # remove the listener. Never fires in test/offline mode (no poll thread).
136
+ def on_change(callable = nil, &block)
137
+ listener = callable || block
138
+ raise ArgumentError, "on_change requires a block or callable" unless listener.respond_to?(:call)
139
+ @mutex.synchronize { @change_listeners << listener }
140
+ proc { @mutex.synchronize { @change_listeners.delete(listener) } }
141
+ end
142
+
48
143
  def destroy
49
144
  @timer&.kill
50
145
  @timer = nil
51
146
  end
52
147
 
53
- def get_flag(name, user)
148
+ # Flag evaluation with the reason the value was reached. :value is the
149
+ # boolean result; :reason is one of the REASON_* constants below.
150
+ FlagDetail = Struct.new(:value, :reason, keyword_init: true)
151
+
152
+ # Reason constants for FlagDetail#reason / get_flag_detail.
153
+ REASON_CLIENT_NOT_READY = "CLIENT_NOT_READY" # no blob fetched/loaded yet
154
+ REASON_FLAG_NOT_FOUND = "FLAG_NOT_FOUND" # blob present, gate absent
155
+ REASON_OFF = "OFF" # gate present but disabled/killed
156
+ REASON_OVERRIDE = "OVERRIDE" # answered by a local override
157
+ REASON_RULE_MATCH = "RULE_MATCH" # evaluated true
158
+ REASON_DEFAULT = "DEFAULT" # evaluated false (rollout/rule)
159
+
160
+ # Evaluate a flag and return why. Telemetry ("gate" beacon) is emitted
161
+ # exactly once here (steps 2–5), never on the OVERRIDE short-circuit.
162
+ def get_flag_detail(name, user)
163
+ key = name.to_s
164
+
165
+ # 1. Override short-circuits before any telemetry (mirrors get_config).
166
+ override = @mutex.synchronize { @flag_overrides[key] if @flag_overrides.key?(key) }
167
+ return FlagDetail.new(value: override, reason: REASON_OVERRIDE) unless override.nil?
168
+
54
169
  @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))
170
+
171
+ flags_blob, gate = @mutex.synchronize { [@flags_blob, @flags_blob&.dig("gates", name)] }
172
+
173
+ # 2. Not initialized — no blob fetched or loaded yet.
174
+ return FlagDetail.new(value: false, reason: REASON_CLIENT_NOT_READY) if flags_blob.nil?
175
+
176
+ # 3. Blob present but this gate isn't in it.
177
+ return FlagDetail.new(value: false, reason: REASON_FLAG_NOT_FOUND) unless gate
178
+
179
+ # 4. Gate present but disabled (or killswitched) — eval_gate would also
180
+ # return false here, but the reason is OFF, not a rollout DEFAULT.
181
+ if Eval.enabled?(gate["killswitch"]) || !Eval.enabled?(gate["enabled"])
182
+ return FlagDetail.new(value: false, reason: REASON_OFF)
183
+ end
184
+
185
+ # 5. Run the canonical evaluator; reason follows the boolean result.
186
+ result = Eval.eval_gate(gate, with_anon_id(user))
187
+ FlagDetail.new(value: result, reason: result ? REASON_RULE_MATCH : REASON_DEFAULT)
58
188
  end
59
189
 
60
- def get_config(name, decode = nil)
190
+ def get_flag(name, user, default: false)
191
+ detail = get_flag_detail(name, user)
192
+ if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
193
+ default
194
+ else
195
+ detail.value
196
+ end
197
+ end
198
+
199
+ def get_config(name, decode = nil, default: nil)
200
+ key = name.to_s
201
+ has_override, override = @mutex.synchronize do
202
+ [@config_overrides.key?(key), @config_overrides[key]]
203
+ end
204
+ if has_override
205
+ return decode ? decode.call(override) : override
206
+ end
207
+
61
208
  @telemetry.emit("config", name)
62
209
  entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
63
- return nil unless entry
210
+ return default unless entry
64
211
  value = entry["value"]
65
212
  decode ? decode.call(value) : value
66
213
  end
67
214
 
68
215
  def get_experiment(name, user, default_params, decode = nil)
216
+ key = name.to_s
217
+ override = @mutex.synchronize { @exp_overrides[key] }
218
+ if override
219
+ params = override[:params]
220
+ params = decode.call(params) if decode
221
+ return Eval::ExperimentResult.new(
222
+ in_experiment: true,
223
+ group: override[:group],
224
+ params: params,
225
+ )
226
+ end
227
+
69
228
  @telemetry.emit("experiment", name)
70
229
  flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
71
230
  exp = exps_blob&.dig("experiments", name)
@@ -89,6 +248,8 @@ module Shipeasy
89
248
  end
90
249
 
91
250
  def track(user_id, event_name, props = {})
251
+ return if @test_mode
252
+
92
253
  payload = JSON.generate({
93
254
  events: [{
94
255
  type: "metric",
@@ -108,6 +269,32 @@ module Shipeasy
108
269
 
109
270
  private
110
271
 
272
+ # Load a parsed snapshot into the local blobs and mark the client ready,
273
+ # without any network. Used by from_snapshot / from_file on a test_mode
274
+ # client so the real evaluator runs against captured data.
275
+ def load_snapshot(flags, experiments)
276
+ @mutex.synchronize do
277
+ @flags_blob = flags
278
+ @exps_blob = experiments
279
+ end
280
+ @initialized = true
281
+ self
282
+ end
283
+
284
+ # Fire each change listener, snapshotting the array under the mutex so a
285
+ # listener that unsubscribes mid-callback doesn't mutate the list we're
286
+ # iterating. Listener errors are isolated (warn, never propagate).
287
+ def notify_change
288
+ listeners = @mutex.synchronize { @change_listeners.dup }
289
+ listeners.each do |listener|
290
+ begin
291
+ listener.call
292
+ rescue => e
293
+ warn "[shipeasy] on_change listener raised: #{e.message}"
294
+ end
295
+ end
296
+ end
297
+
111
298
  # Normalise the user hash to string keys and, when the caller passed no
112
299
  # explicit unit, default anonymous_id to the request's __se_anon_id (set by
113
300
  # RackMiddleware). Lets `get_flag("x", {})` bucket anonymous traffic with
@@ -162,6 +349,8 @@ module Shipeasy
162
349
  @flags_etag = etag if etag
163
350
  @flags_blob = blob
164
351
  end
352
+ # New data arrived (200, not the 304 returned above) — notify listeners.
353
+ notify_change
165
354
  interval
166
355
  end
167
356
 
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.3.0"
3
+ VERSION = "1.4.0"
4
4
  end
5
5
  end
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.4.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