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 +4 -4
- data/README.md +138 -0
- data/lib/shipeasy/sdk/flags_client.rb +196 -7
- data/lib/shipeasy/sdk/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
|
|
4
|
+
data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
|
data/lib/shipeasy/sdk/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-06-19 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|