shipeasy-sdk 1.6.0 → 2.0.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 +85 -33
- data/lib/shipeasy/client.rb +62 -0
- data/lib/shipeasy/config.rb +86 -5
- data/lib/shipeasy/{sdk/flags_client.rb → engine.rb} +123 -9
- data/lib/shipeasy/sdk/anon_id.rb +1 -1
- data/lib/shipeasy/sdk/openfeature.rb +10 -10
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +8 -7
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97efde389fa7a5adc1ffdab3951d09837f435bd529d8f6c3b7e3d45387472628
|
|
4
|
+
data.tar.gz: 46f183776d94ccbddd8781e39d3ea5e56e6795cf26f3292f1482c2886b7d0836
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 010654d1322131ab21b467fdff8176914e427558a5a620762c4417378f68c5cc5f3b189e7b0cdb8de6b741d2232e7c1552b8fd551bc3fce19b09e91263e18d12
|
|
7
|
+
data.tar.gz: 24730826e8f2de0ac3f1546ab4f42b2301bcd8f83bdaea0b9c50487d7b90d5b445470827199de73c4794bc16b2ddeed3e18fc44079c2000cd0c92aeb07f50e55
|
data/README.md
CHANGED
|
@@ -14,34 +14,56 @@ gem "shipeasy-sdk"
|
|
|
14
14
|
|
|
15
15
|
## Quickstart (Rails)
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Two parts: **configure once** at boot, then build a **user-bound
|
|
18
|
+
`Shipeasy::Client`** per request via its constructor.
|
|
19
|
+
|
|
20
|
+
`config/initializers/shipeasy.rb`:
|
|
18
21
|
|
|
19
22
|
```ruby
|
|
20
23
|
Shipeasy.configure do |c|
|
|
21
24
|
c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
|
|
25
|
+
|
|
26
|
+
# Optional: map YOUR user object → the Shipeasy attribute hash. Runs once,
|
|
27
|
+
# in the Shipeasy::Client constructor. Omit it and the object you pass to
|
|
28
|
+
# Shipeasy::Client.new IS the attribute hash (identity).
|
|
29
|
+
c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
30
|
+
|
|
22
31
|
c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
|
|
23
32
|
c.profile = "default"
|
|
24
33
|
end
|
|
25
34
|
```
|
|
26
35
|
|
|
27
|
-
|
|
36
|
+
`configure` builds the single global engine for you and kicks off a one-shot
|
|
37
|
+
fetch (fire-and-forget). Anywhere in your app, construct a bound client and call
|
|
38
|
+
the getters with **no user argument** — the user is bound at construction:
|
|
28
39
|
|
|
29
40
|
```ruby
|
|
30
|
-
|
|
41
|
+
flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
|
|
31
42
|
|
|
32
|
-
if
|
|
43
|
+
if flags.get_flag("new_checkout") # NO user arg
|
|
33
44
|
# ship it
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
color =
|
|
37
|
-
result =
|
|
38
|
-
|
|
47
|
+
color = flags.get_config("button_color")
|
|
48
|
+
result = flags.get_experiment("checkout_cta", { label: "Buy now" })
|
|
49
|
+
panic = flags.get_killswitch("payments")
|
|
39
50
|
```
|
|
40
51
|
|
|
41
|
-
`Shipeasy
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
`Shipeasy::Client` is **cheap**: it delegates evaluation to the single engine
|
|
53
|
+
built by `configure` — it never opens its own connection, fetches, or polls.
|
|
54
|
+
Construct one per user / per request.
|
|
55
|
+
|
|
56
|
+
Event ingestion (`track`) lives on the engine — `Shipeasy.engine` is the global
|
|
57
|
+
one `configure` registered:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Shipeasy.engine.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
> **Upgrading from 1.x?** The heavyweight client was renamed
|
|
64
|
+
> `Shipeasy::SDK::FlagsClient` → `Shipeasy::Engine`, and `Shipeasy::Client` is
|
|
65
|
+
> now the lightweight user-bound handle. The legacy `Shipeasy.flags.get_flag(name, user)`
|
|
66
|
+
> singleton still works.
|
|
45
67
|
|
|
46
68
|
In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
|
|
47
69
|
|
|
@@ -61,7 +83,7 @@ wiring** — `get_flag` on an anonymous request just works:
|
|
|
61
83
|
|
|
62
84
|
```ruby
|
|
63
85
|
# current_user is nil → buckets on the __se_anon_id cookie automatically
|
|
64
|
-
Shipeasy.
|
|
86
|
+
Shipeasy::Client.new({}).get_flag("new_checkout")
|
|
65
87
|
```
|
|
66
88
|
|
|
67
89
|
An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
|
|
@@ -86,7 +108,8 @@ require "shipeasy-sdk"
|
|
|
86
108
|
|
|
87
109
|
Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
|
|
88
110
|
|
|
89
|
-
|
|
111
|
+
# With no `attributes` transform, the hash you pass IS the attribute map.
|
|
112
|
+
Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout")
|
|
90
113
|
```
|
|
91
114
|
|
|
92
115
|
The Rails view helpers (`i18n_*`) are not loaded outside Rails, so the
|
|
@@ -99,9 +122,9 @@ short-lived function. Build the client explicitly and call `init_once`
|
|
|
99
122
|
for a single synchronous fetch:
|
|
100
123
|
|
|
101
124
|
```ruby
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
engine = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
|
|
126
|
+
engine.init_once
|
|
127
|
+
engine.get_flag("new_checkout", user)
|
|
105
128
|
```
|
|
106
129
|
|
|
107
130
|
## Lifecycle escape hatch
|
|
@@ -115,6 +138,31 @@ client.init
|
|
|
115
138
|
at_exit { client.destroy }
|
|
116
139
|
```
|
|
117
140
|
|
|
141
|
+
## Server-side rendering (SSR)
|
|
142
|
+
|
|
143
|
+
Emit the request's evaluated flags as a declarative `<script>` tag so the
|
|
144
|
+
browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
|
|
145
|
+
in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
|
|
146
|
+
hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
|
|
147
|
+
browser buckets identically to the server.
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
user = { "user_id" => "u_123" }
|
|
151
|
+
|
|
152
|
+
# Two tags for the document <head>. The PUBLIC client key (not the server
|
|
153
|
+
# key) goes on the i18n loader tag.
|
|
154
|
+
head = client.bootstrap_script_tag(user, anon_id: anon_id) +
|
|
155
|
+
client.i18n_script_tag(client_key, profile: "en:prod")
|
|
156
|
+
|
|
157
|
+
# …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
|
|
158
|
+
boot = client.evaluate(user)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
|
|
162
|
+
`https://cdn.shipeasy.ai`). In **Rails**, the existing
|
|
163
|
+
`Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
|
|
164
|
+
loader tag from your app config.
|
|
165
|
+
|
|
118
166
|
## Default values
|
|
119
167
|
|
|
120
168
|
`get_flag` and `get_config` take an optional `default:` returned **only when the
|
|
@@ -181,9 +229,9 @@ endpoints:
|
|
|
181
229
|
```
|
|
182
230
|
|
|
183
231
|
```ruby
|
|
184
|
-
client = Shipeasy::
|
|
232
|
+
client = Shipeasy::Engine.from_file("snapshot.json")
|
|
185
233
|
# or, from already-parsed blobs:
|
|
186
|
-
client = Shipeasy::
|
|
234
|
+
client = Shipeasy::Engine.from_snapshot(flags: flags_body, experiments: exps_body)
|
|
187
235
|
|
|
188
236
|
client.get_flag("new_checkout", user) # real evaluation, no network
|
|
189
237
|
client.get_experiment("checkout_cta", user, {})
|
|
@@ -210,7 +258,7 @@ snapshot.
|
|
|
210
258
|
|
|
211
259
|
For unit/integration tests you want a client that does **zero network** and
|
|
212
260
|
returns exactly the values you seed — no api_key, no fetch, no poll thread, no
|
|
213
|
-
telemetry, no metric ingestion. Build one with `
|
|
261
|
+
telemetry, no metric ingestion. Build one with `Shipeasy::Engine.for_testing` and
|
|
214
262
|
seed each entity with the `override_*` setters (Statsig-style local overrides).
|
|
215
263
|
An override always wins over the fetched blob, so the getters answer
|
|
216
264
|
deterministically:
|
|
@@ -218,7 +266,7 @@ deterministically:
|
|
|
218
266
|
```ruby
|
|
219
267
|
require "shipeasy-sdk"
|
|
220
268
|
|
|
221
|
-
client = Shipeasy::
|
|
269
|
+
client = Shipeasy::Engine.for_testing
|
|
222
270
|
# init / init_once are no-ops here — nothing is ever fetched.
|
|
223
271
|
|
|
224
272
|
# Flags (boolean)
|
|
@@ -247,31 +295,35 @@ client.clear_overrides
|
|
|
247
295
|
|
|
248
296
|
The same `override_flag` / `override_config` / `override_experiment` /
|
|
249
297
|
`clear_overrides` setters also work on a **normal** live client (built with
|
|
250
|
-
`
|
|
298
|
+
`Shipeasy::Engine.new(...)`), so you can pin one value in local development while the
|
|
251
299
|
rest comes from the fetched blob.
|
|
252
300
|
|
|
253
|
-
###
|
|
301
|
+
### Global engine / bound client
|
|
254
302
|
|
|
255
|
-
`Shipeasy.
|
|
256
|
-
|
|
257
|
-
`Shipeasy.
|
|
258
|
-
|
|
303
|
+
`Shipeasy.engine` (registered by `configure`) and `Shipeasy.flags` (legacy
|
|
304
|
+
singleton) both fetch over the network, so in tests stub them to a
|
|
305
|
+
`for_testing` engine. `Shipeasy::Client.new(user)` reads `Shipeasy.engine`, so
|
|
306
|
+
stubbing the engine also covers the bound-client path:
|
|
259
307
|
|
|
260
308
|
```ruby
|
|
261
309
|
# RSpec
|
|
262
310
|
before do
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
allow(Shipeasy).to receive(:
|
|
311
|
+
test_engine = Shipeasy::Engine.for_testing
|
|
312
|
+
test_engine.override_flag("new_checkout", true)
|
|
313
|
+
allow(Shipeasy).to receive(:engine).and_return(test_engine)
|
|
314
|
+
allow(Shipeasy).to receive(:flags).and_return(test_engine) # legacy path
|
|
315
|
+
|
|
316
|
+
# Shipeasy::Client.new(user).get_flag("new_checkout") now => true
|
|
266
317
|
end
|
|
267
318
|
```
|
|
268
319
|
|
|
269
320
|
## Configuration
|
|
270
321
|
|
|
271
|
-
| Parameter
|
|
272
|
-
|
|
|
273
|
-
| `api_key`
|
|
274
|
-
| `base_url`
|
|
322
|
+
| Parameter | Default | Description |
|
|
323
|
+
| ------------ | ----------------------------- | ------------------------------------------------------------------- |
|
|
324
|
+
| `api_key` | (required) | SDK key from the Shipeasy dashboard |
|
|
325
|
+
| `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
|
|
326
|
+
| `attributes` | identity (`->(u) { u }`) | Callable mapping your user object → the Shipeasy attribute hash |
|
|
275
327
|
|
|
276
328
|
## Documentation
|
|
277
329
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module Shipeasy
|
|
2
|
+
# A lightweight, user-bound evaluation handle. Construct one per user/request
|
|
3
|
+
# via its real constructor:
|
|
4
|
+
#
|
|
5
|
+
# flags = Shipeasy::Client.new(current_user)
|
|
6
|
+
# flags.get_flag("new_checkout") # NO user arg — bound at construction
|
|
7
|
+
# flags.get_experiment("price_test", { price: 9 })
|
|
8
|
+
#
|
|
9
|
+
# It is cheap: it delegates every evaluation to the single global engine built
|
|
10
|
+
# by `Shipeasy.configure { … }`. It does NOT open its own HTTP connection,
|
|
11
|
+
# fetch, or start a poll timer.
|
|
12
|
+
#
|
|
13
|
+
# The configured `attributes` transform (see Shipeasy::Configuration#attributes)
|
|
14
|
+
# runs ONCE here, in the constructor, against the raw user object you pass.
|
|
15
|
+
# The resulting attribute hash is then enriched with the request-scoped
|
|
16
|
+
# anonymous_id (when you supplied neither user_id nor anonymous_id) and bound,
|
|
17
|
+
# so every getter reads the same bag.
|
|
18
|
+
#
|
|
19
|
+
# Raises if constructed before `Shipeasy.configure` registered an engine.
|
|
20
|
+
class Client
|
|
21
|
+
# The resolved attribute hash this handle evaluates against.
|
|
22
|
+
attr_reader :attributes
|
|
23
|
+
|
|
24
|
+
def initialize(user)
|
|
25
|
+
engine = Shipeasy.engine
|
|
26
|
+
if engine.nil?
|
|
27
|
+
raise Error, "Shipeasy::Client.new(user) called before Shipeasy.configure " \
|
|
28
|
+
"{ |c| c.api_key = … }. Call Shipeasy.configure once at app boot."
|
|
29
|
+
end
|
|
30
|
+
@engine = engine
|
|
31
|
+
# Run the configured attributes transform (default identity), then apply
|
|
32
|
+
# the existing anon-id merge exactly as the per-call engine path does.
|
|
33
|
+
mapped = Shipeasy.attributes_transform.call(user)
|
|
34
|
+
@attributes = engine.bind_attributes(mapped)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_flag(name, default: false)
|
|
38
|
+
@engine.get_flag(name, @attributes, default: default)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_flag_detail(name)
|
|
42
|
+
@engine.get_flag_detail(name, @attributes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configs are not user-scoped, but exposed here for one-stop ergonomics.
|
|
46
|
+
def get_config(name, decode = nil, default: nil)
|
|
47
|
+
@engine.get_config(name, decode, default: default)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_experiment(name, default_params, decode = nil)
|
|
51
|
+
@engine.get_experiment(name, @attributes, default_params, decode)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Killswitches are not user-scoped; forwarded straight to the engine.
|
|
55
|
+
def get_killswitch(name, switch_key = nil)
|
|
56
|
+
@engine.get_killswitch(name, switch_key)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Raised by Shipeasy::Client when constructed before Shipeasy.configure.
|
|
61
|
+
class Error < StandardError; end
|
|
62
|
+
end
|
data/lib/shipeasy/config.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Single configuration object for the Shipeasy gem.
|
|
2
2
|
#
|
|
3
3
|
# Covers both subsystems:
|
|
4
|
-
# - SDK / experimentation (api_key, base_url) — drives
|
|
4
|
+
# - SDK / experimentation (api_key, base_url) — drives Engine
|
|
5
5
|
# - i18n / string manager (public_key, profile, cdn_base_url, ...) — drives
|
|
6
6
|
# the Rails view helpers and label fetcher
|
|
7
7
|
#
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# end
|
|
15
15
|
#
|
|
16
16
|
# Anything not set falls back to the defaults below. The same Shipeasy.config
|
|
17
|
-
# is read by
|
|
17
|
+
# is read by Engine and the Rails helpers, so there is one place to
|
|
18
18
|
# point environment variables at.
|
|
19
19
|
|
|
20
20
|
module Shipeasy
|
|
@@ -22,6 +22,18 @@ module Shipeasy
|
|
|
22
22
|
# ---- experimentation / SDK ----
|
|
23
23
|
attr_accessor :api_key, :base_url
|
|
24
24
|
|
|
25
|
+
# Optional transform from YOUR user object (any shape) to the Shipeasy
|
|
26
|
+
# attribute hash every flag/experiment evaluation uses. A callable
|
|
27
|
+
# (lambda/proc or anything responding to #call). Default = identity (the
|
|
28
|
+
# user object is assumed to already BE the attribute hash). Runs once, in
|
|
29
|
+
# the Shipeasy::Client constructor.
|
|
30
|
+
#
|
|
31
|
+
# Shipeasy.configure do |c|
|
|
32
|
+
# c.api_key = ENV["SHIPEASY_SERVER_KEY"]
|
|
33
|
+
# c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
34
|
+
# end
|
|
35
|
+
attr_accessor :attributes
|
|
36
|
+
|
|
25
37
|
# ---- i18n / string manager ----
|
|
26
38
|
attr_accessor :public_key, :profile, :default_chunk,
|
|
27
39
|
:cdn_base_url, :loader_url,
|
|
@@ -29,6 +41,7 @@ module Shipeasy
|
|
|
29
41
|
|
|
30
42
|
def initialize
|
|
31
43
|
@base_url = "https://edge.shipeasy.dev"
|
|
44
|
+
@attributes = nil
|
|
32
45
|
|
|
33
46
|
@profile = "default"
|
|
34
47
|
@default_chunk = "index"
|
|
@@ -45,8 +58,68 @@ module Shipeasy
|
|
|
45
58
|
@config ||= Configuration.new
|
|
46
59
|
end
|
|
47
60
|
|
|
61
|
+
# Configure the gem once at boot. In addition to populating the shared
|
|
62
|
+
# Configuration, this builds and registers the ONE global Shipeasy::Engine
|
|
63
|
+
# (first-config-wins) from the api_key/base_url and kicks off its one-shot
|
|
64
|
+
# fetch (fire-and-forget) so `Shipeasy::Client.new(user).get_flag(...)`
|
|
65
|
+
# resolves against real rules with no explicit init call.
|
|
66
|
+
#
|
|
67
|
+
# Shipeasy.configure do |c|
|
|
68
|
+
# c.api_key = ENV["SHIPEASY_SERVER_KEY"]
|
|
69
|
+
# c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
70
|
+
# end
|
|
71
|
+
#
|
|
72
|
+
# Shipeasy::Client.new(current_user).get_flag("new_checkout")
|
|
73
|
+
#
|
|
74
|
+
# Long-running servers that also want the background poll can call
|
|
75
|
+
# `Shipeasy.engine.init` after configure.
|
|
48
76
|
def configure
|
|
49
77
|
yield config
|
|
78
|
+
register_engine!(config) if config.api_key
|
|
79
|
+
config
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The resolved attributes transform (callable). Default = identity, so a
|
|
83
|
+
# user object that is already the attribute hash is used verbatim.
|
|
84
|
+
def attributes_transform
|
|
85
|
+
transform = config.attributes
|
|
86
|
+
if transform.nil?
|
|
87
|
+
->(user) { user }
|
|
88
|
+
elsif transform.respond_to?(:call)
|
|
89
|
+
transform
|
|
90
|
+
else
|
|
91
|
+
raise Error, "Shipeasy.configure { |c| c.attributes = … } must be a callable (e.g. a lambda)"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The single global engine registered by configure, or nil if configure has
|
|
96
|
+
# not run (or ran without an api_key). Shipeasy::Client reads this.
|
|
97
|
+
def engine
|
|
98
|
+
pid = Process.pid
|
|
99
|
+
if @engine && @engine_pid != pid
|
|
100
|
+
# Post-fork: the parent's poll thread didn't survive. Rebuild lazily
|
|
101
|
+
# from the stored config in this child process.
|
|
102
|
+
@engine = nil
|
|
103
|
+
register_engine!(config) if config.api_key
|
|
104
|
+
end
|
|
105
|
+
@engine
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build + register the one global engine (first-config-wins). Fires the
|
|
109
|
+
# one-shot fetch fire-and-forget. Idempotent within a process.
|
|
110
|
+
def register_engine!(cfg)
|
|
111
|
+
return @engine if @engine && @engine_pid == Process.pid
|
|
112
|
+
@engine_pid = Process.pid
|
|
113
|
+
engine = Engine.new(api_key: cfg.api_key, base_url: cfg.base_url)
|
|
114
|
+
@engine = engine
|
|
115
|
+
# Capture +engine+ in the closure (not the @engine ivar, which a concurrent
|
|
116
|
+
# reset/reconfigure could nil out before the thread runs).
|
|
117
|
+
Thread.new do
|
|
118
|
+
engine.init_once
|
|
119
|
+
rescue => e
|
|
120
|
+
warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
engine
|
|
50
123
|
end
|
|
51
124
|
|
|
52
125
|
# Reset the config back to defaults — primarily for tests.
|
|
@@ -55,9 +128,12 @@ module Shipeasy
|
|
|
55
128
|
@flags_pid = nil
|
|
56
129
|
@flags&.destroy
|
|
57
130
|
@flags = nil
|
|
131
|
+
@engine&.destroy
|
|
132
|
+
@engine = nil
|
|
133
|
+
@engine_pid = nil
|
|
58
134
|
end
|
|
59
135
|
|
|
60
|
-
# Lazy, fork-safe singleton
|
|
136
|
+
# Lazy, fork-safe singleton Engine. The first call from each
|
|
61
137
|
# process spawns a fresh client + poll thread — including post-fork
|
|
62
138
|
# workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
|
|
63
139
|
# straight from a controller without holding a constant or worrying
|
|
@@ -70,7 +146,12 @@ module Shipeasy
|
|
|
70
146
|
#
|
|
71
147
|
# The first request that touches `Shipeasy.flags.*` triggers init().
|
|
72
148
|
# For serverless / Lambda where you want a single fetch with no thread,
|
|
73
|
-
# build the
|
|
149
|
+
# build the engine explicitly: `Shipeasy::Engine.new(...).init_once`.
|
|
150
|
+
#
|
|
151
|
+
# NOTE: this remains a separate, polling engine from the one configure()
|
|
152
|
+
# registers (Shipeasy.engine). New code should prefer the
|
|
153
|
+
# Shipeasy.configure + Shipeasy::Client.new(user) front door; `Shipeasy.flags`
|
|
154
|
+
# is retained for the legacy `Shipeasy.flags.get_flag(name, user)` style.
|
|
74
155
|
def flags
|
|
75
156
|
pid = Process.pid
|
|
76
157
|
if @flags && @flags_pid != pid
|
|
@@ -81,7 +162,7 @@ module Shipeasy
|
|
|
81
162
|
end
|
|
82
163
|
@flags ||= begin
|
|
83
164
|
@flags_pid = pid
|
|
84
|
-
client =
|
|
165
|
+
client = Engine.new(
|
|
85
166
|
api_key: config.api_key,
|
|
86
167
|
base_url: config.base_url,
|
|
87
168
|
)
|
|
@@ -2,16 +2,36 @@ require "net/http"
|
|
|
2
2
|
require "uri"
|
|
3
3
|
require "json"
|
|
4
4
|
require "thread"
|
|
5
|
-
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "
|
|
9
|
-
require_relative "
|
|
5
|
+
require "cgi"
|
|
6
|
+
require_relative "sdk/eval"
|
|
7
|
+
require_relative "sdk/telemetry"
|
|
8
|
+
require_relative "sdk/anon_id"
|
|
9
|
+
require_relative "sdk/sticky_store"
|
|
10
|
+
require_relative "sdk/see"
|
|
10
11
|
|
|
11
12
|
module Shipeasy
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
# The heavyweight engine: owns the api key, HTTP transport, the blob cache,
|
|
14
|
+
# the background poll timer, init/init_once, local overrides, track, and
|
|
15
|
+
# see()/default-client wiring. Was `Shipeasy::SDK::FlagsClient` before 2.0;
|
|
16
|
+
# renamed to a clean top-level `Shipeasy::Engine` when the lightweight
|
|
17
|
+
# user-bound `Shipeasy::Client` became the primary front door.
|
|
18
|
+
#
|
|
19
|
+
# Most apps never construct an Engine directly — `Shipeasy.configure { … }`
|
|
20
|
+
# builds and registers the one global engine for you. Construct one explicitly
|
|
21
|
+
# only for advanced/serverless flows (multiple keys, offline snapshots).
|
|
22
|
+
class Engine
|
|
23
|
+
# Internal collaborators still live under Shipeasy::SDK; alias them so the
|
|
24
|
+
# body below can keep referring to them unqualified after the class moved
|
|
25
|
+
# out from under the SDK namespace.
|
|
26
|
+
Eval = Shipeasy::SDK::Eval
|
|
27
|
+
Telemetry = Shipeasy::SDK::Telemetry
|
|
28
|
+
AnonId = Shipeasy::SDK::AnonId
|
|
29
|
+
See = Shipeasy::SDK::See
|
|
30
|
+
|
|
14
31
|
DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
|
|
32
|
+
# CDN origin serving the static loader scripts (/sdk/bootstrap.js,
|
|
33
|
+
# /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
|
|
34
|
+
DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
|
|
15
35
|
|
|
16
36
|
def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil)
|
|
17
37
|
@api_key = api_key
|
|
@@ -30,7 +50,7 @@ module Shipeasy
|
|
|
30
50
|
@sticky_store = sticky_store
|
|
31
51
|
# Test mode: no network, ever. init/init_once/track become no-ops and
|
|
32
52
|
# evaluation answers come purely from local overrides. Built via the
|
|
33
|
-
#
|
|
53
|
+
# Engine.for_testing factory; see clear_overrides / override_*.
|
|
34
54
|
@test_mode = test_mode
|
|
35
55
|
# Per-evaluation usage telemetry. ON by default; pass
|
|
36
56
|
# disable_telemetry: true to opt out. See telemetry.rb.
|
|
@@ -271,6 +291,93 @@ module Shipeasy
|
|
|
271
291
|
result
|
|
272
292
|
end
|
|
273
293
|
|
|
294
|
+
# Public hook for the bound Shipeasy::Client: normalise an attribute hash
|
|
295
|
+
# and apply the request-scoped anonymous_id merge ONCE, at Client
|
|
296
|
+
# construction, exactly as every per-call getter does internally.
|
|
297
|
+
def bind_attributes(user)
|
|
298
|
+
with_anon_id(user)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Read a killswitch from the cached flags blob. Without +switch_key+,
|
|
302
|
+
# returns true when the whole killswitch is killed. With +switch_key+,
|
|
303
|
+
# returns true when that specific per-key switch is on. Unknown
|
|
304
|
+
# killswitches / switches return false. Not user-scoped.
|
|
305
|
+
def get_killswitch(name, switch_key = nil)
|
|
306
|
+
@telemetry.emit("ks", name)
|
|
307
|
+
ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
|
|
308
|
+
return false unless ks
|
|
309
|
+
if switch_key.nil?
|
|
310
|
+
Eval.enabled?(ks["killed"])
|
|
311
|
+
else
|
|
312
|
+
Eval.enabled?(ks.dig("switches", switch_key.to_s))
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Batch-evaluate every loaded gate, config and experiment for +user+ into
|
|
317
|
+
# a bootstrap payload (+{ "flags" => ..., "configs" => ..., "experiments"
|
|
318
|
+
# => ..., "killswitches" => ... }+) keyed to match the browser SDK's
|
|
319
|
+
# window.__SE_BOOTSTRAP shape. Local overrides win. Killswitches are folded
|
|
320
|
+
# into per-gate evaluation, so the standalone +killswitches+ map is empty
|
|
321
|
+
# for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
|
|
322
|
+
def evaluate(user)
|
|
323
|
+
u = with_anon_id(user)
|
|
324
|
+
flags_blob, exps_blob, flag_ov, config_ov, exp_ov, sticky = @mutex.synchronize do
|
|
325
|
+
[@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup,
|
|
326
|
+
@exp_overrides.dup, @sticky_store]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
flags = {}
|
|
330
|
+
(flags_blob&.dig("gates") || {}).each do |name, gate|
|
|
331
|
+
flags[name] = flag_ov.key?(name) ? flag_ov[name] : Eval.eval_gate(gate, u)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
configs = {}
|
|
335
|
+
(flags_blob&.dig("configs") || {}).each do |name, entry|
|
|
336
|
+
configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
experiments = {}
|
|
340
|
+
(exps_blob&.dig("experiments") || {}).each do |name, exp|
|
|
341
|
+
if exp_ov.key?(name)
|
|
342
|
+
ov = exp_ov[name]
|
|
343
|
+
experiments[name] = { "inExperiment" => true, "group" => ov[:group], "params" => ov[:params] }
|
|
344
|
+
next
|
|
345
|
+
end
|
|
346
|
+
r = Eval.eval_experiment(exp, flags_blob, exps_blob, u, exp_name: name, sticky_store: sticky)
|
|
347
|
+
experiments[name] = { "inExperiment" => r.in_experiment, "group" => r.group, "params" => r.params }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
{ "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Return the cross-platform SSR bootstrap <script> tag for a request:
|
|
354
|
+
# se-bootstrap.js reads its data-* attributes and hydrates
|
|
355
|
+
# window.__SE_BOOTSTRAP (and writes the anon cookie). No key is embedded.
|
|
356
|
+
def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
|
|
357
|
+
payload = evaluate(user)
|
|
358
|
+
base = cdn_base(base_url)
|
|
359
|
+
attrs = [
|
|
360
|
+
"data-se-bootstrap",
|
|
361
|
+
attr("data-flags", JSON.generate(payload["flags"])),
|
|
362
|
+
attr("data-configs", JSON.generate(payload["configs"])),
|
|
363
|
+
attr("data-experiments", JSON.generate(payload["experiments"])),
|
|
364
|
+
attr("data-killswitches", JSON.generate(payload["killswitches"])),
|
|
365
|
+
attr("data-i18n-profile", i18n_profile || "en:prod"),
|
|
366
|
+
attr("data-api-url", base),
|
|
367
|
+
]
|
|
368
|
+
attrs << attr("data-anon-id", anon_id) if anon_id && !anon_id.empty?
|
|
369
|
+
%(<script src="#{CGI.escapeHTML("#{base}/sdk/bootstrap.js")}" #{attrs.join(' ')}></script>)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Return the i18n loader <script> tag (framework-agnostic; the Rails view
|
|
373
|
+
# helper Shipeasy::I18n::ViewHelpers#i18n_script_tag is separate). The
|
|
374
|
+
# loader fetches translations for the profile using the PUBLIC client key.
|
|
375
|
+
def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
|
|
376
|
+
base = cdn_base(base_url)
|
|
377
|
+
%(<script src="#{CGI.escapeHTML("#{base}/sdk/i18n/loader.js")}" ) +
|
|
378
|
+
%(#{attr('data-key', client_key)} #{attr('data-profile', profile || 'en:prod')}></script>)
|
|
379
|
+
end
|
|
380
|
+
|
|
274
381
|
def track(user_id, event_name, props = {})
|
|
275
382
|
return if @test_mode
|
|
276
383
|
|
|
@@ -427,6 +534,14 @@ module Shipeasy
|
|
|
427
534
|
v.nil? || v == ""
|
|
428
535
|
end
|
|
429
536
|
|
|
537
|
+
def cdn_base(override)
|
|
538
|
+
(override && !override.empty? ? override : DEFAULT_CDN_BASE).chomp("/")
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def attr(name, value)
|
|
542
|
+
%(#{name}="#{CGI.escapeHTML(value.to_s)}")
|
|
543
|
+
end
|
|
544
|
+
|
|
430
545
|
def start_poll
|
|
431
546
|
@timer = Thread.new do
|
|
432
547
|
loop do
|
|
@@ -499,6 +614,5 @@ module Shipeasy
|
|
|
499
614
|
http.read_timeout = 10
|
|
500
615
|
http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
|
|
501
616
|
end
|
|
502
|
-
end
|
|
503
617
|
end
|
|
504
618
|
end
|
data/lib/shipeasy/sdk/anon_id.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Shipeasy
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# The anon id RackMiddleware resolved for the current request, or nil when
|
|
36
|
-
# no middleware ran (e.g. a background job).
|
|
36
|
+
# no middleware ran (e.g. a background job). The Engine falls back to this
|
|
37
37
|
# as the default anonymous_id, so evaluations need no per-call wiring.
|
|
38
38
|
def current
|
|
39
39
|
Thread.current[THREAD_KEY]
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
# require "open_feature/sdk"
|
|
12
12
|
# require "shipeasy/sdk/openfeature"
|
|
13
13
|
#
|
|
14
|
-
# client = Shipeasy::
|
|
14
|
+
# client = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
|
|
15
15
|
# client.init
|
|
16
16
|
#
|
|
17
17
|
# OpenFeature::SDK.configure do |config|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
# on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
|
|
23
23
|
# evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
|
|
24
24
|
#
|
|
25
|
-
# Pure adapter over `
|
|
25
|
+
# Pure adapter over `Shipeasy::Engine` — no change to evaluation. Boolean values map
|
|
26
26
|
# onto gates (`get_flag_detail`); string/number/integer/float/object map onto
|
|
27
27
|
# dynamic configs (`get_config`).
|
|
28
28
|
|
|
@@ -37,12 +37,12 @@ rescue LoadError => e
|
|
|
37
37
|
"gem \"openfeature-sdk\". (#{e.message})"
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
require_relative "
|
|
40
|
+
require_relative "../engine"
|
|
41
41
|
|
|
42
42
|
module Shipeasy
|
|
43
43
|
module OpenFeature
|
|
44
44
|
# Shipeasy OpenFeature provider (server paradigm). Wraps a
|
|
45
|
-
# `Shipeasy::
|
|
45
|
+
# `Shipeasy::Engine`; evaluation is local against the cached blob,
|
|
46
46
|
# so resolution is effectively synchronous.
|
|
47
47
|
class Provider
|
|
48
48
|
OF = ::OpenFeature::SDK::Provider
|
|
@@ -56,12 +56,12 @@ module Shipeasy
|
|
|
56
56
|
# FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
|
|
57
57
|
# CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
|
|
58
58
|
REASON_MAP = {
|
|
59
|
-
Shipeasy::
|
|
60
|
-
Shipeasy::
|
|
61
|
-
Shipeasy::
|
|
62
|
-
Shipeasy::
|
|
63
|
-
Shipeasy::
|
|
64
|
-
Shipeasy::
|
|
59
|
+
Shipeasy::Engine::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
|
|
60
|
+
Shipeasy::Engine::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
|
|
61
|
+
Shipeasy::Engine::REASON_OFF => [OF::Reason::DISABLED, nil],
|
|
62
|
+
Shipeasy::Engine::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
|
|
63
|
+
Shipeasy::Engine::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
|
|
64
|
+
Shipeasy::Engine::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
|
|
65
65
|
}.freeze
|
|
66
66
|
|
|
67
67
|
attr_reader :metadata
|
data/lib/shipeasy/sdk/version.rb
CHANGED
data/lib/shipeasy-sdk.rb
CHANGED
|
@@ -3,7 +3,8 @@ require_relative "shipeasy/config"
|
|
|
3
3
|
require_relative "shipeasy/sdk/murmur3"
|
|
4
4
|
require_relative "shipeasy/sdk/eval"
|
|
5
5
|
require_relative "shipeasy/sdk/sticky_store"
|
|
6
|
-
require_relative "shipeasy/
|
|
6
|
+
require_relative "shipeasy/engine"
|
|
7
|
+
require_relative "shipeasy/client"
|
|
7
8
|
require_relative "shipeasy/sdk/anon_id"
|
|
8
9
|
require_relative "shipeasy/sdk/rack_middleware"
|
|
9
10
|
require_relative "shipeasy/i18n/label_fetcher"
|
|
@@ -19,16 +20,16 @@ end
|
|
|
19
20
|
|
|
20
21
|
module Shipeasy
|
|
21
22
|
module SDK
|
|
22
|
-
# Convenience constructor. Reads api_key + base_url
|
|
23
|
-
# config when omitted
|
|
24
|
-
#
|
|
23
|
+
# Convenience constructor for a heavyweight Engine. Reads api_key + base_url
|
|
24
|
+
# from the gem-wide config when omitted. Most apps should prefer
|
|
25
|
+
# `Shipeasy.configure { … }` + `Shipeasy::Client.new(user)` instead.
|
|
25
26
|
def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
|
|
26
|
-
|
|
27
|
+
Shipeasy::Engine.new(api_key: api_key, base_url: base_url)
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# ---- see() module-level facade --------------------------------------
|
|
30
31
|
#
|
|
31
|
-
# Backed by a default client, registered when
|
|
32
|
+
# Backed by a default client, registered when an Engine is constructed
|
|
32
33
|
# (last constructed wins). Mirrors the package-level see() in the TS/Python
|
|
33
34
|
# SDKs so callers can `Shipeasy::SDK.see(e).causes_the(...).to(...)` without
|
|
34
35
|
# threading a client reference through every call site. A call before any
|
|
@@ -38,7 +39,7 @@ module Shipeasy
|
|
|
38
39
|
@see_default_mutex = Mutex.new
|
|
39
40
|
|
|
40
41
|
# Register the client backing the module-level see() funcs. Called
|
|
41
|
-
# automatically from
|
|
42
|
+
# automatically from Engine#initialize; also exposed for explicit use.
|
|
42
43
|
def self.set_default_client(client)
|
|
43
44
|
@see_default_mutex.synchronize { @see_default_client = client }
|
|
44
45
|
client
|
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:
|
|
4
|
+
version: 2.0.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-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -65,13 +65,14 @@ files:
|
|
|
65
65
|
- LICENSE
|
|
66
66
|
- README.md
|
|
67
67
|
- lib/shipeasy-sdk.rb
|
|
68
|
+
- lib/shipeasy/client.rb
|
|
68
69
|
- lib/shipeasy/config.rb
|
|
70
|
+
- lib/shipeasy/engine.rb
|
|
69
71
|
- lib/shipeasy/i18n/label_fetcher.rb
|
|
70
72
|
- lib/shipeasy/i18n/railtie.rb
|
|
71
73
|
- lib/shipeasy/i18n/view_helpers.rb
|
|
72
74
|
- lib/shipeasy/sdk/anon_id.rb
|
|
73
75
|
- lib/shipeasy/sdk/eval.rb
|
|
74
|
-
- lib/shipeasy/sdk/flags_client.rb
|
|
75
76
|
- lib/shipeasy/sdk/murmur3.rb
|
|
76
77
|
- lib/shipeasy/sdk/openfeature.rb
|
|
77
78
|
- lib/shipeasy/sdk/rack_middleware.rb
|