shipeasy-sdk 1.7.0 → 2.1.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 +87 -268
- data/bin/shipeasy-skill +8 -0
- data/docs/skill/SKILL.md +148 -0
- data/lib/shipeasy/client.rb +71 -0
- data/lib/shipeasy/config.rb +271 -5
- data/lib/shipeasy/{sdk/flags_client.rb → engine.rb} +56 -11
- data/lib/shipeasy/sdk/anon_id.rb +1 -1
- data/lib/shipeasy/sdk/openfeature.rb +42 -15
- data/lib/shipeasy/sdk/skill.rb +76 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +8 -7
- metadata +9 -4
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,39 @@ module Shipeasy
|
|
|
22
22
|
# ---- experimentation / SDK ----
|
|
23
23
|
attr_accessor :api_key, :base_url
|
|
24
24
|
|
|
25
|
+
# Advanced `configure` options — threaded into the global Engine `configure`
|
|
26
|
+
# builds, so callers never construct an Engine themselves:
|
|
27
|
+
# - env (default "prod"): deployment tag on see() events + usage telemetry.
|
|
28
|
+
# - disable_telemetry (default false): opt out of per-eval usage telemetry.
|
|
29
|
+
# - telemetry_url: override the telemetry endpoint (rarely needed).
|
|
30
|
+
# - private_attributes: attribute keys stripped from every outbound event
|
|
31
|
+
# before it leaves the process (they still drive targeting locally).
|
|
32
|
+
# - sticky_store: pin a user's experiment group across re-buckets.
|
|
33
|
+
attr_accessor :env, :disable_telemetry, :telemetry_url,
|
|
34
|
+
:private_attributes, :sticky_store
|
|
35
|
+
|
|
36
|
+
# Fetch lifecycle for the global engine `configure` builds:
|
|
37
|
+
# - init (default true): fire a one-shot fetch fire-and-forget so the first
|
|
38
|
+
# `Shipeasy::Client.new(user).get_flag(...)` resolves against real rules
|
|
39
|
+
# (ideal for serverless / short-lived processes).
|
|
40
|
+
# - poll (default false): start the background poll (initial fetch +
|
|
41
|
+
# periodic refresh) for a long-running server, so flags stay fresh
|
|
42
|
+
# without a redeploy. Configuration owns the lifecycle — you never call
|
|
43
|
+
# `engine.init` yourself.
|
|
44
|
+
attr_accessor :init, :poll
|
|
45
|
+
|
|
46
|
+
# Optional transform from YOUR user object (any shape) to the Shipeasy
|
|
47
|
+
# attribute hash every flag/experiment evaluation uses. A callable
|
|
48
|
+
# (lambda/proc or anything responding to #call). Default = identity (the
|
|
49
|
+
# user object is assumed to already BE the attribute hash). Runs once, in
|
|
50
|
+
# the Shipeasy::Client constructor.
|
|
51
|
+
#
|
|
52
|
+
# Shipeasy.configure do |c|
|
|
53
|
+
# c.api_key = ENV["SHIPEASY_SERVER_KEY"]
|
|
54
|
+
# c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
55
|
+
# end
|
|
56
|
+
attr_accessor :attributes
|
|
57
|
+
|
|
25
58
|
# ---- i18n / string manager ----
|
|
26
59
|
attr_accessor :public_key, :profile, :default_chunk,
|
|
27
60
|
:cdn_base_url, :loader_url,
|
|
@@ -29,6 +62,14 @@ module Shipeasy
|
|
|
29
62
|
|
|
30
63
|
def initialize
|
|
31
64
|
@base_url = "https://edge.shipeasy.dev"
|
|
65
|
+
@attributes = nil
|
|
66
|
+
@init = true
|
|
67
|
+
@poll = false
|
|
68
|
+
@env = "prod"
|
|
69
|
+
@disable_telemetry = false
|
|
70
|
+
@telemetry_url = nil
|
|
71
|
+
@private_attributes = nil
|
|
72
|
+
@sticky_store = nil
|
|
32
73
|
|
|
33
74
|
@profile = "default"
|
|
34
75
|
@default_chunk = "index"
|
|
@@ -45,8 +86,225 @@ module Shipeasy
|
|
|
45
86
|
@config ||= Configuration.new
|
|
46
87
|
end
|
|
47
88
|
|
|
89
|
+
# Configure the gem once at boot. In addition to populating the shared
|
|
90
|
+
# Configuration, this builds and registers the ONE global Shipeasy::Engine
|
|
91
|
+
# (first-config-wins) from the api_key/base_url and kicks off its one-shot
|
|
92
|
+
# fetch (fire-and-forget) so `Shipeasy::Client.new(user).get_flag(...)`
|
|
93
|
+
# resolves against real rules with no explicit init call.
|
|
94
|
+
#
|
|
95
|
+
# Shipeasy.configure do |c|
|
|
96
|
+
# c.api_key = ENV["SHIPEASY_SERVER_KEY"]
|
|
97
|
+
# c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
98
|
+
# end
|
|
99
|
+
#
|
|
100
|
+
# Shipeasy::Client.new(current_user).get_flag("new_checkout")
|
|
101
|
+
#
|
|
102
|
+
# Long-running servers that also want the background poll can call
|
|
103
|
+
# `Shipeasy.engine.init` after configure.
|
|
48
104
|
def configure
|
|
49
105
|
yield config
|
|
106
|
+
register_engine!(config) if config.api_key
|
|
107
|
+
config
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The resolved attributes transform (callable). Default = identity, so a
|
|
111
|
+
# user object that is already the attribute hash is used verbatim.
|
|
112
|
+
def attributes_transform
|
|
113
|
+
transform = config.attributes
|
|
114
|
+
if transform.nil?
|
|
115
|
+
->(user) { user }
|
|
116
|
+
elsif transform.respond_to?(:call)
|
|
117
|
+
transform
|
|
118
|
+
else
|
|
119
|
+
raise Error, "Shipeasy.configure { |c| c.attributes = … } must be a callable (e.g. a lambda)"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The single global engine registered by configure, or nil if configure has
|
|
124
|
+
# not run (or ran without an api_key). Shipeasy::Client reads this.
|
|
125
|
+
def engine
|
|
126
|
+
pid = Process.pid
|
|
127
|
+
if @engine && @engine_pid != pid
|
|
128
|
+
# Post-fork: the parent's poll thread didn't survive. Rebuild lazily
|
|
129
|
+
# from the stored config in this child process.
|
|
130
|
+
@engine = nil
|
|
131
|
+
register_engine!(config) if config.api_key
|
|
132
|
+
end
|
|
133
|
+
@engine
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Build + register the one global engine (first-config-wins). Kicks off the
|
|
137
|
+
# configured fetch lifecycle (one-shot by default; the background poll when
|
|
138
|
+
# `c.poll = true`) fire-and-forget. Idempotent within a process.
|
|
139
|
+
def register_engine!(cfg)
|
|
140
|
+
return @engine if @engine && @engine_pid == Process.pid
|
|
141
|
+
@engine_pid = Process.pid
|
|
142
|
+
engine = Engine.new(
|
|
143
|
+
api_key: cfg.api_key,
|
|
144
|
+
base_url: cfg.base_url,
|
|
145
|
+
env: cfg.env,
|
|
146
|
+
disable_telemetry: cfg.disable_telemetry,
|
|
147
|
+
telemetry_url: cfg.telemetry_url,
|
|
148
|
+
private_attributes: cfg.private_attributes,
|
|
149
|
+
sticky_store: cfg.sticky_store,
|
|
150
|
+
)
|
|
151
|
+
@engine = engine
|
|
152
|
+
# Capture +engine+ in the closure (not the @engine ivar, which a concurrent
|
|
153
|
+
# reset/reconfigure could nil out before the thread runs).
|
|
154
|
+
if cfg.poll
|
|
155
|
+
Thread.new do
|
|
156
|
+
engine.init # initial fetch + background poll thread
|
|
157
|
+
rescue => e
|
|
158
|
+
warn "[shipeasy] configure(poll) background poll failed: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
elsif cfg.init
|
|
161
|
+
Thread.new do
|
|
162
|
+
engine.init_once
|
|
163
|
+
rescue => e
|
|
164
|
+
warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
engine
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ---- configure() test/offline siblings -----------------------------------
|
|
171
|
+
#
|
|
172
|
+
# Drop-in siblings of `Shipeasy.configure` for tests and offline evaluation.
|
|
173
|
+
# Unlike `configure` (first-config-wins), these REPLACE the registered global
|
|
174
|
+
# engine, so a suite can reconfigure between cases. After either, you read the
|
|
175
|
+
# same way: `Shipeasy::Client.new(user)`.
|
|
176
|
+
|
|
177
|
+
# Configure Shipeasy in TEST MODE — no api key, zero network, ever. Seed the
|
|
178
|
+
# values your code under test should see via the override args, then read
|
|
179
|
+
# through the ordinary `Shipeasy::Client.new(user)`:
|
|
180
|
+
#
|
|
181
|
+
# Shipeasy.configure_for_testing(flags: { "new_checkout" => true })
|
|
182
|
+
# Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout") # => true
|
|
183
|
+
#
|
|
184
|
+
# flags: { name => bool } forced get_flag results
|
|
185
|
+
# configs: { name => value } forced get_config results
|
|
186
|
+
# experiments: { name => [group, params] } forced enrolments
|
|
187
|
+
# attributes: same transform as configure (default identity)
|
|
188
|
+
def configure_for_testing(flags: nil, configs: nil, experiments: nil, attributes: nil)
|
|
189
|
+
engine = Engine.for_testing
|
|
190
|
+
apply_overrides(engine, flags, configs, experiments)
|
|
191
|
+
install_global_engine(engine, attributes)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Configure Shipeasy OFFLINE — evaluate the REAL rules from an in-memory
|
|
195
|
+
# snapshot or a JSON file, with no network. Provide exactly one source:
|
|
196
|
+
#
|
|
197
|
+
# snapshot: { "flags" => <body of /sdk/flags>, "experiments" => <body of /sdk/experiments> }
|
|
198
|
+
# path: "snapshot.json" (a JSON file of the same shape)
|
|
199
|
+
#
|
|
200
|
+
# Optional flags/configs/experiments overrides layer on top (same shapes as
|
|
201
|
+
# configure_for_testing). Replaces any previously-configured engine.
|
|
202
|
+
def configure_for_offline(snapshot: nil, path: nil, flags: nil, configs: nil, experiments: nil, attributes: nil)
|
|
203
|
+
engine =
|
|
204
|
+
if path
|
|
205
|
+
Engine.from_file(path)
|
|
206
|
+
elsif snapshot
|
|
207
|
+
s = snapshot.transform_keys(&:to_s)
|
|
208
|
+
Engine.from_snapshot(flags: s["flags"], experiments: s["experiments"])
|
|
209
|
+
else
|
|
210
|
+
raise Error, "Shipeasy.configure_for_offline requires snapshot: or path:"
|
|
211
|
+
end
|
|
212
|
+
apply_overrides(engine, flags, configs, experiments)
|
|
213
|
+
install_global_engine(engine, attributes)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ---- package-level helpers (so callers never name the Engine) -------------
|
|
217
|
+
|
|
218
|
+
# On-the-spot overrides layered on top of whatever configure_for_testing /
|
|
219
|
+
# configure_for_offline (or a live configure) set up — they win over the blob
|
|
220
|
+
# until clear_overrides. Require a prior configure* call.
|
|
221
|
+
def override_flag(name, value)
|
|
222
|
+
require_engine("override_flag").override_flag(name, value)
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def override_config(name, value)
|
|
227
|
+
require_engine("override_config").override_config(name, value)
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def override_experiment(name, group, params)
|
|
232
|
+
require_engine("override_experiment").override_experiment(name, group, params)
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Drop EVERY override — including the seed from configure_for_testing (test
|
|
237
|
+
# mode has no blob beneath); under configure_for_offline it reverts to the
|
|
238
|
+
# snapshot.
|
|
239
|
+
def clear_overrides
|
|
240
|
+
require_engine("clear_overrides").clear_overrides
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Register a poll listener fired after a background poll fetches NEW data
|
|
245
|
+
# (HTTP 200, not 304). Requires configure(poll: true). Returns an unsubscribe
|
|
246
|
+
# proc. Accepts a block or any callable.
|
|
247
|
+
def on_change(callable = nil, &block)
|
|
248
|
+
require_engine("on_change").on_change(callable, &block)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# SSR tag helpers — delegate to the configured global engine, so you never
|
|
252
|
+
# touch it. i18n_script_tag carries the PUBLIC client key (not the server
|
|
253
|
+
# key); bootstrap_script_tag embeds no key.
|
|
254
|
+
def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
|
|
255
|
+
require_engine("i18n_script_tag").i18n_script_tag(client_key, profile: profile, base_url: base_url)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
|
|
259
|
+
require_engine("bootstrap_script_tag").bootstrap_script_tag(
|
|
260
|
+
user, anon_id: anon_id, i18n_profile: i18n_profile, base_url: base_url
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# see() structured error reporting — package-level, dispatched through the
|
|
265
|
+
# last-constructed default client (the engine configure built). Never raises
|
|
266
|
+
# into caller code; a call before any client exists warns and no-ops.
|
|
267
|
+
def see(problem)
|
|
268
|
+
Shipeasy::SDK.see(problem)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def see_violation(name)
|
|
272
|
+
Shipeasy::SDK.see_violation(name)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def control_flow_exception(err)
|
|
276
|
+
Shipeasy::SDK.control_flow_exception(err)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Replace the registered global engine + attributes transform (used by the
|
|
280
|
+
# configure_for_* siblings — unlike configure, they replace so a test suite
|
|
281
|
+
# can reconfigure between cases). Returns the engine.
|
|
282
|
+
def install_global_engine(engine, attributes)
|
|
283
|
+
config.attributes = attributes
|
|
284
|
+
@engine = engine
|
|
285
|
+
@engine_pid = Process.pid
|
|
286
|
+
engine
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Apply the configure_for_* override args onto an engine.
|
|
290
|
+
def apply_overrides(engine, flags, configs, experiments)
|
|
291
|
+
(flags || {}).each { |name, value| engine.override_flag(name, value) }
|
|
292
|
+
(configs || {}).each { |name, value| engine.override_config(name, value) }
|
|
293
|
+
(experiments || {}).each do |name, spec|
|
|
294
|
+
group, params = spec # spec is [group, params]
|
|
295
|
+
engine.override_experiment(name, group, params)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The global engine, or raise a helpful error naming the package-level fn the
|
|
300
|
+
# caller used before any configure*.
|
|
301
|
+
def require_engine(fn_name)
|
|
302
|
+
e = engine
|
|
303
|
+
return e unless e.nil?
|
|
304
|
+
|
|
305
|
+
raise Error, "Shipeasy.#{fn_name} called before Shipeasy.configure " \
|
|
306
|
+
"{ |c| c.api_key = … } (or configure_for_testing / " \
|
|
307
|
+
"configure_for_offline). Call one once at app boot."
|
|
50
308
|
end
|
|
51
309
|
|
|
52
310
|
# Reset the config back to defaults — primarily for tests.
|
|
@@ -55,9 +313,12 @@ module Shipeasy
|
|
|
55
313
|
@flags_pid = nil
|
|
56
314
|
@flags&.destroy
|
|
57
315
|
@flags = nil
|
|
316
|
+
@engine&.destroy
|
|
317
|
+
@engine = nil
|
|
318
|
+
@engine_pid = nil
|
|
58
319
|
end
|
|
59
320
|
|
|
60
|
-
# Lazy, fork-safe singleton
|
|
321
|
+
# Lazy, fork-safe singleton Engine. The first call from each
|
|
61
322
|
# process spawns a fresh client + poll thread — including post-fork
|
|
62
323
|
# workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
|
|
63
324
|
# straight from a controller without holding a constant or worrying
|
|
@@ -70,7 +331,12 @@ module Shipeasy
|
|
|
70
331
|
#
|
|
71
332
|
# The first request that touches `Shipeasy.flags.*` triggers init().
|
|
72
333
|
# For serverless / Lambda where you want a single fetch with no thread,
|
|
73
|
-
# build the
|
|
334
|
+
# build the engine explicitly: `Shipeasy::Engine.new(...).init_once`.
|
|
335
|
+
#
|
|
336
|
+
# NOTE: this remains a separate, polling engine from the one configure()
|
|
337
|
+
# registers (Shipeasy.engine). New code should prefer the
|
|
338
|
+
# Shipeasy.configure + Shipeasy::Client.new(user) front door; `Shipeasy.flags`
|
|
339
|
+
# is retained for the legacy `Shipeasy.flags.get_flag(name, user)` style.
|
|
74
340
|
def flags
|
|
75
341
|
pid = Process.pid
|
|
76
342
|
if @flags && @flags_pid != pid
|
|
@@ -81,7 +347,7 @@ module Shipeasy
|
|
|
81
347
|
end
|
|
82
348
|
@flags ||= begin
|
|
83
349
|
@flags_pid = pid
|
|
84
|
-
client =
|
|
350
|
+
client = Engine.new(
|
|
85
351
|
api_key: config.api_key,
|
|
86
352
|
base_url: config.base_url,
|
|
87
353
|
)
|
|
@@ -3,15 +3,31 @@ require "uri"
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "thread"
|
|
5
5
|
require "cgi"
|
|
6
|
-
require_relative "eval"
|
|
7
|
-
require_relative "telemetry"
|
|
8
|
-
require_relative "anon_id"
|
|
9
|
-
require_relative "sticky_store"
|
|
10
|
-
require_relative "see"
|
|
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"
|
|
11
11
|
|
|
12
12
|
module Shipeasy
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
|
|
15
31
|
DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
|
|
16
32
|
# CDN origin serving the static loader scripts (/sdk/bootstrap.js,
|
|
17
33
|
# /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
|
|
@@ -34,7 +50,7 @@ module Shipeasy
|
|
|
34
50
|
@sticky_store = sticky_store
|
|
35
51
|
# Test mode: no network, ever. init/init_once/track become no-ops and
|
|
36
52
|
# evaluation answers come purely from local overrides. Built via the
|
|
37
|
-
#
|
|
53
|
+
# Engine.for_testing factory; see clear_overrides / override_*.
|
|
38
54
|
@test_mode = test_mode
|
|
39
55
|
# Per-evaluation usage telemetry. ON by default; pass
|
|
40
56
|
# disable_telemetry: true to opt out. See telemetry.rb.
|
|
@@ -74,15 +90,19 @@ module Shipeasy
|
|
|
74
90
|
|
|
75
91
|
# Build a no-network, immediately-usable client for tests. Telemetry is
|
|
76
92
|
# disabled, init/init_once/track are no-ops (never fetch), and no api_key
|
|
77
|
-
# is required.
|
|
93
|
+
# is required. The client is immediately READY against an empty blob (so a
|
|
94
|
+
# missing gate resolves FLAG_NOT_FOUND, not CLIENT_NOT_READY — parity with
|
|
95
|
+
# the other SDKs). Seed it with override_flag / override_config /
|
|
78
96
|
# override_experiment, then call the normal getters.
|
|
79
97
|
def self.for_testing(env: "prod")
|
|
80
|
-
new(
|
|
98
|
+
client = new(
|
|
81
99
|
api_key: "test",
|
|
82
100
|
env: env,
|
|
83
101
|
disable_telemetry: true,
|
|
84
102
|
test_mode: true,
|
|
85
103
|
)
|
|
104
|
+
client.send(:load_snapshot, {}, {})
|
|
105
|
+
client
|
|
86
106
|
end
|
|
87
107
|
|
|
88
108
|
# Build an offline client from a JSON snapshot file. The file holds the
|
|
@@ -275,6 +295,32 @@ module Shipeasy
|
|
|
275
295
|
result
|
|
276
296
|
end
|
|
277
297
|
|
|
298
|
+
# Public hook for the bound Shipeasy::Client: normalise an attribute hash
|
|
299
|
+
# and apply the request-scoped anonymous_id merge ONCE, at Client
|
|
300
|
+
# construction, exactly as every per-call getter does internally.
|
|
301
|
+
def bind_attributes(user)
|
|
302
|
+
with_anon_id(user)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Read a killswitch from the cached flags blob. Without +switch_key+,
|
|
306
|
+
# returns true when the whole killswitch is killed. With +switch_key+,
|
|
307
|
+
# returns true when that specific named per-key switch is on — and when
|
|
308
|
+
# the key isn't configured on the killswitch, FALLS BACK to the top-level
|
|
309
|
+
# value (so an unconfigured key behaves exactly like the no-key call).
|
|
310
|
+
# Unknown killswitches return false. Not user-scoped.
|
|
311
|
+
def get_killswitch(name, switch_key = nil)
|
|
312
|
+
@telemetry.emit("ks", name)
|
|
313
|
+
ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
|
|
314
|
+
return false unless ks
|
|
315
|
+
unless switch_key.nil?
|
|
316
|
+
switches = ks["switches"] || {}
|
|
317
|
+
key = switch_key.to_s
|
|
318
|
+
return Eval.enabled?(switches[key]) if switches.key?(key)
|
|
319
|
+
# key not configured → fall through to the top-level value
|
|
320
|
+
end
|
|
321
|
+
Eval.enabled?(ks["killed"])
|
|
322
|
+
end
|
|
323
|
+
|
|
278
324
|
# Batch-evaluate every loaded gate, config and experiment for +user+ into
|
|
279
325
|
# a bootstrap payload (+{ "flags" => ..., "configs" => ..., "experiments"
|
|
280
326
|
# => ..., "killswitches" => ... }+) keyed to match the browser SDK's
|
|
@@ -576,6 +622,5 @@ module Shipeasy
|
|
|
576
622
|
http.read_timeout = 10
|
|
577
623
|
http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
|
|
578
624
|
end
|
|
579
|
-
end
|
|
580
625
|
end
|
|
581
626
|
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,18 +11,17 @@
|
|
|
11
11
|
# require "open_feature/sdk"
|
|
12
12
|
# require "shipeasy/sdk/openfeature"
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
# client.init
|
|
14
|
+
# Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
|
|
16
15
|
#
|
|
17
16
|
# OpenFeature::SDK.configure do |config|
|
|
18
|
-
# config.set_provider(Shipeasy::OpenFeature::Provider.new
|
|
17
|
+
# config.set_provider(Shipeasy::OpenFeature::Provider.new) # uses the configured global
|
|
19
18
|
# end
|
|
20
19
|
#
|
|
21
20
|
# of = OpenFeature::SDK.build_client
|
|
22
21
|
# on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
|
|
23
22
|
# evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
|
|
24
23
|
#
|
|
25
|
-
# Pure adapter over `
|
|
24
|
+
# Pure adapter over `Shipeasy::Engine` — no change to evaluation. Boolean values map
|
|
26
25
|
# onto gates (`get_flag_detail`); string/number/integer/float/object map onto
|
|
27
26
|
# dynamic configs (`get_config`).
|
|
28
27
|
|
|
@@ -37,12 +36,14 @@ rescue LoadError => e
|
|
|
37
36
|
"gem \"openfeature-sdk\". (#{e.message})"
|
|
38
37
|
end
|
|
39
38
|
|
|
40
|
-
require_relative "
|
|
39
|
+
require_relative "../engine"
|
|
40
|
+
require_relative "../config"
|
|
41
|
+
require_relative "../client"
|
|
41
42
|
|
|
42
43
|
module Shipeasy
|
|
43
44
|
module OpenFeature
|
|
44
45
|
# Shipeasy OpenFeature provider (server paradigm). Wraps a
|
|
45
|
-
# `Shipeasy::
|
|
46
|
+
# `Shipeasy::Engine`; evaluation is local against the cached blob,
|
|
46
47
|
# so resolution is effectively synchronous.
|
|
47
48
|
class Provider
|
|
48
49
|
OF = ::OpenFeature::SDK::Provider
|
|
@@ -56,17 +57,27 @@ module Shipeasy
|
|
|
56
57
|
# FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
|
|
57
58
|
# CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
|
|
58
59
|
REASON_MAP = {
|
|
59
|
-
Shipeasy::
|
|
60
|
-
Shipeasy::
|
|
61
|
-
Shipeasy::
|
|
62
|
-
Shipeasy::
|
|
63
|
-
Shipeasy::
|
|
64
|
-
Shipeasy::
|
|
60
|
+
Shipeasy::Engine::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
|
|
61
|
+
Shipeasy::Engine::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
|
|
62
|
+
Shipeasy::Engine::REASON_OFF => [OF::Reason::DISABLED, nil],
|
|
63
|
+
Shipeasy::Engine::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
|
|
64
|
+
Shipeasy::Engine::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
|
|
65
|
+
Shipeasy::Engine::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
|
|
65
66
|
}.freeze
|
|
66
67
|
|
|
67
68
|
attr_reader :metadata
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
# Construct the provider. With no argument it resolves the global engine
|
|
71
|
+
# configured via `Shipeasy.configure(...)`, so callers never build an
|
|
72
|
+
# Engine themselves — construct it AFTER your `Shipeasy.configure` call.
|
|
73
|
+
# Pass an explicit engine only for advanced/multi-key setups.
|
|
74
|
+
def initialize(client = nil)
|
|
75
|
+
client ||= Shipeasy.engine
|
|
76
|
+
if client.nil?
|
|
77
|
+
raise Shipeasy::Error, "Shipeasy::OpenFeature::Provider.new needs " \
|
|
78
|
+
"Shipeasy.configure { |c| c.api_key = … } to have run first " \
|
|
79
|
+
"(or pass an explicit engine)."
|
|
80
|
+
end
|
|
70
81
|
@client = client
|
|
71
82
|
@metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
|
|
72
83
|
end
|
|
@@ -123,12 +134,28 @@ module Shipeasy
|
|
|
123
134
|
end
|
|
124
135
|
|
|
125
136
|
# OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
|
|
126
|
-
def track(tracking_event_name, evaluation_context: nil,
|
|
137
|
+
def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
|
|
127
138
|
ctx = normalize_context(evaluation_context)
|
|
128
139
|
user_id = ctx["targeting_key"] || ctx["user_id"]
|
|
129
140
|
return if user_id.nil? || user_id.to_s.empty?
|
|
130
141
|
|
|
131
|
-
props =
|
|
142
|
+
# Base props = the evaluation-context attributes (minus the identity
|
|
143
|
+
# keys), with the tracking-event details merged on top.
|
|
144
|
+
props = ctx.reject { |k, _| k == "targeting_key" || k == "user_id" }
|
|
145
|
+
|
|
146
|
+
detail_fields = if tracking_event_details.respond_to?(:fields)
|
|
147
|
+
tracking_event_details.fields.transform_keys(&:to_s)
|
|
148
|
+
elsif tracking_event_details.is_a?(Hash)
|
|
149
|
+
tracking_event_details.transform_keys(&:to_s)
|
|
150
|
+
else
|
|
151
|
+
{}
|
|
152
|
+
end
|
|
153
|
+
props = props.merge(detail_fields)
|
|
154
|
+
|
|
155
|
+
if tracking_event_details.respond_to?(:value) && !tracking_event_details.value.nil?
|
|
156
|
+
props["value"] = tracking_event_details.value
|
|
157
|
+
end
|
|
158
|
+
|
|
132
159
|
@client.track(user_id, tracking_event_name, props)
|
|
133
160
|
end
|
|
134
161
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Shipeasy
|
|
6
|
+
module SDK
|
|
7
|
+
# `shipeasy-skill` — install the bundled Shipeasy agent skill into a project.
|
|
8
|
+
#
|
|
9
|
+
# RubyGems has no safe post-install hook (gems don't run code on install;
|
|
10
|
+
# installers run non-interactively), so installing the skill is an explicit,
|
|
11
|
+
# opt-in command:
|
|
12
|
+
#
|
|
13
|
+
# shipeasy-skill install # → .claude/skills/shipeasy-ruby/SKILL.md
|
|
14
|
+
# shipeasy-skill install --dir path/ # custom destination (file or dir)
|
|
15
|
+
# shipeasy-skill install --force # overwrite an existing file
|
|
16
|
+
# shipeasy-skill print # write the skill to stdout
|
|
17
|
+
#
|
|
18
|
+
# The skill (`docs/skill/SKILL.md`) is shipped inside the gem, so this reads
|
|
19
|
+
# it with no network — relative to this file, which works both from an
|
|
20
|
+
# installed gem and a source checkout.
|
|
21
|
+
module Skill
|
|
22
|
+
DEFAULT_DEST = ".claude/skills/shipeasy-ruby/SKILL.md"
|
|
23
|
+
|
|
24
|
+
# The bundled SKILL.md, read from docs/skill/SKILL.md (a sibling of lib/ in
|
|
25
|
+
# both the installed gem and a source checkout).
|
|
26
|
+
def self.skill_text
|
|
27
|
+
path = File.expand_path("../../../docs/skill/SKILL.md", __dir__)
|
|
28
|
+
File.read(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Copy the skill to +dest+ (a file, or a directory it's written into).
|
|
32
|
+
def self.install(dest, force: false)
|
|
33
|
+
dest = File.join(dest, "SKILL.md") if File.directory?(dest) || File.extname(dest).empty?
|
|
34
|
+
if File.exist?(dest) && !force
|
|
35
|
+
warn "shipeasy-skill: refusing to overwrite #{dest} — pass --force"
|
|
36
|
+
return 1
|
|
37
|
+
end
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
39
|
+
File.write(dest, skill_text)
|
|
40
|
+
puts "shipeasy-skill: installed the Shipeasy agent skill → #{dest}"
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.main(argv)
|
|
45
|
+
cmd = argv.shift
|
|
46
|
+
case cmd
|
|
47
|
+
when "install"
|
|
48
|
+
dest = DEFAULT_DEST
|
|
49
|
+
force = false
|
|
50
|
+
while (arg = argv.shift)
|
|
51
|
+
case arg
|
|
52
|
+
when "--dir" then dest = argv.shift
|
|
53
|
+
when "--force" then force = true
|
|
54
|
+
else
|
|
55
|
+
warn "shipeasy-skill: unknown argument #{arg}"
|
|
56
|
+
return 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
install(dest, force: force)
|
|
60
|
+
when "print"
|
|
61
|
+
puts skill_text
|
|
62
|
+
0
|
|
63
|
+
else
|
|
64
|
+
puts <<~USAGE
|
|
65
|
+
shipeasy-skill — install the Shipeasy Ruby agent skill into your project.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
shipeasy-skill install [--dir PATH] [--force] copy SKILL.md (default: #{DEFAULT_DEST})
|
|
69
|
+
shipeasy-skill print print the skill to stdout
|
|
70
|
+
USAGE
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/shipeasy/sdk/version.rb
CHANGED