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 +4 -4
- data/README.md +138 -0
- data/lib/shipeasy/sdk/eval.rb +38 -3
- data/lib/shipeasy/sdk/flags_client.rb +251 -9
- data/lib/shipeasy/sdk/openfeature.rb +204 -0
- data/lib/shipeasy/sdk/sticky_store.rb +39 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ff3dbc0f1f6f19484edafcb75460928392fd43278036b68ed2c8179f9d21ac4d
|
|
4
|
+
data.tar.gz: 04d7e38426d7be6c00e504749c6a33441d88db79f70e1eae533fa9fc115b94d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
data/lib/shipeasy/sdk/eval.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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(
|
|
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
|
-
**(
|
|
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
|
data/lib/shipeasy/sdk/version.rb
CHANGED
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.
|
|
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-
|
|
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
|