shipeasy-sdk 2.3.1 → 3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c724f285483ed9509f862e463c829e8dbba248aa9c7f8affb256d76ce54a7a1
4
- data.tar.gz: a021e596201e1492075c1474c5b1a8ce2acb30af7f96120c07fa04cd0ffa0b22
3
+ metadata.gz: 3a34364fd54857fe0b04997063d9d2bbb6296e281a3db8b7389efd84c5f1b4a1
4
+ data.tar.gz: bb1c68285633d2f1a5709286197b9a0aacaf7c78d950908e414f3fde64f44144
5
5
  SHA512:
6
- metadata.gz: 6b38d42715ecc331f4efec186c85f22514802790a2aba8671a393ab9fb2f5c2f07121659501182f27fab68b02b0ad464230c69148fca3c7e944b18b3e07d2b21
7
- data.tar.gz: 0b05bfd9dd7ab084b5fa40ee5b783395921baf4b3d089c137e42ba91845415a75647ad32c714811a99fa04191270c5d1c6a0f61351d7e1e914e7e70fadc73f16
6
+ metadata.gz: f4f9a1149dabefce47743afb04bec2e3f3e6dceac88c9e1a48a2cdb187b87bce0c5c9853cc68afe3f64f7a521fe8dcb4d5017c679ea362f19f8b598d71b3dbcb
7
+ data.tar.gz: b82a76824d4d0a90cc481453c5036a53cdb1adb30e74630ac843c6d6582fa294391e440b0d29b044eebf25071eb264bbab3a74491fac3939a815064ca1fd0f92
data/README.md CHANGED
@@ -59,8 +59,8 @@ flags = Shipeasy::Client.new(current_user)
59
59
 
60
60
  flags.get_flag("new_checkout") # NO user arg — bound at construction
61
61
  flags.get_config("button_color")
62
- result = flags.get_experiment("checkout_cta", { label: "Buy" })
63
- flags.log_exposure("checkout_cta") # at the decision point
62
+ assignment = flags.universe("checkout").assign # <=1 experiment; auto-logs exposure
63
+ assignment.get("label", "Buy") # variant ?? universe default ?? fallback
64
64
  flags.track("purchase", { revenue: 49 }) # on conversion
65
65
  flags.get_killswitch("payments")
66
66
  ```
@@ -78,7 +78,7 @@ Constructing `Shipeasy::Client.new(user)` before `Shipeasy.configure` raises
78
78
  | [Feature flags](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/flags.md) | `get_flag`, `get_flag_detail`, defaults. |
79
79
  | [Dynamic configs](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configs.md) | `get_config`, typed decode, defaults. |
80
80
  | [Kill switches](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/killswitches.md) | `get_killswitch`, named switches. |
81
- | [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `get_experiment`, `log_exposure`, `track`. |
81
+ | [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `universe(name).assign`, `Assignment#get`, `track`. |
82
82
  | [Internationalization](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/i18n.md) | Rails view helpers + the SSR loader tag. |
83
83
  | [Error reporting](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/error-reporting.md) | `see()` structured error reporting. |
84
84
  | [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) | `configure_for_testing` / `configure_for_offline`, overrides. |
@@ -109,12 +109,15 @@ client = Shipeasy::Client.new({ "user_id" => "u_123" })
109
109
  client.get_flag("new_checkout") # => true
110
110
  client.get_config("billing_copy") # => { "title" => "Welcome" }
111
111
 
112
- result = client.get_experiment("checkout_button", { "color" => "blue" })
113
- result.in_experiment # => true
114
- result.group # => "treatment"
115
- result.params # => { "color" => "green" }
112
+ # An experiment override surfaces through universe(name).assign once the loaded
113
+ # blob maps that experiment to its universe (an offline snapshot does this; a
114
+ # bare configure_for_testing has no blob). Read via the universe:
115
+ assignment = client.universe("checkout").assign
116
+ assignment.enrolled? # => true
117
+ assignment.group # => "treatment"
118
+ assignment.get("color") # => "green"
116
119
 
117
- # track / log_exposure are no-ops in test mode — safe to call, send nothing
120
+ # track / assign exposures are no-ops in test mode — safe to call, send nothing
118
121
  client.track("purchase", { amount: 49 })
119
122
  ```
120
123
 
data/docs/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shipeasy-ruby
3
- description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/get_experiment/get_killswitch, track, testing, OpenFeature.
3
+ description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/universe(name).assign/get_killswitch, track, testing, OpenFeature.
4
4
  ---
5
5
 
6
6
  # Shipeasy Ruby SDK
@@ -46,8 +46,10 @@ the default (one-shot fetch, no thread) is serverless-friendly.
46
46
 
47
47
  ## Evaluate (bound `Client.new(user)` — NO user arg)
48
48
 
49
- Bind the user once per request, then call without re-passing it — `track` and
50
- `log_exposure` are on the bound client too, so experiments are end-to-end here:
49
+ Bind the user once per request, then call without re-passing it — experiments
50
+ are read by **universe** (a mutual-exclusion pool: the unit lands in <=1
51
+ experiment), and `track` is on the same bound client, so experiments are
52
+ end-to-end here:
51
53
 
52
54
  ```ruby
53
55
  flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
@@ -55,10 +57,13 @@ flags = Shipeasy::Client.new(current_user) # runs the attributes transform onc
55
57
  flags.get_flag("new_checkout") # bool; default: only when unresolved
56
58
  flags.get_config("button_color", default: "blue")
57
59
  flags.get_killswitch("payments") # true = killed; optional switch_key
58
- result = flags.get_experiment("checkout_cta", { label: "Buy now" })
59
- # result.in_experiment / result.group / result.params
60
60
 
61
- flags.log_exposure("checkout_cta") # at the decision point
61
+ # Ask the UNIVERSE, not the experiment. Returns an Assignment (never raises):
62
+ # .name / .group → nil when not enrolled · .enrolled? → group non-nil
63
+ # .get(field, fallback = nil) → variant override ?? universe default ?? fallback
64
+ assignment = flags.universe("checkout").assign # auto-logs one deduped exposure when enrolled
65
+ render_cta(assignment.get("label", "Buy now"))
66
+
62
67
  flags.track("purchase", { revenue: 49 }) # conversion / metric event
63
68
  ```
64
69
 
@@ -4,7 +4,7 @@ module Shipeasy
4
4
  #
5
5
  # flags = Shipeasy::Client.new(current_user)
6
6
  # flags.get_flag("new_checkout") # NO user arg — bound at construction
7
- # flags.get_experiment("price_test", { price: 9 })
7
+ # flags.universe("checkout").assign # NO user arg — bound at construction
8
8
  #
9
9
  # It is cheap: it delegates every evaluation to the single global engine built
10
10
  # by `Shipeasy.configure { … }`. It does NOT open its own HTTP connection,
@@ -34,35 +34,75 @@ module Shipeasy
34
34
  @attributes = engine.bind_attributes(mapped)
35
35
  end
36
36
 
37
+ # NOTE on fail-safe reads: the engine's runtime methods already never raise
38
+ # (each is wrapped in Engine#safe_run). The extra defensive rescue here is a
39
+ # belt-and-braces guard so even an unexpected failure BEFORE the engine call
40
+ # (e.g. an @attributes deref) still returns the documented safe default
41
+ # rather than propagating into product code.
42
+
37
43
  def get_flag(name, default: false)
38
44
  @engine.get_flag(name, @attributes, default: default)
45
+ rescue StandardError => e
46
+ Shipeasy::Logging.error "[shipeasy] Client#get_flag('#{name}') failed — returning default: #{e.message}"
47
+ default
39
48
  end
40
49
 
41
50
  def get_flag_detail(name)
42
51
  @engine.get_flag_detail(name, @attributes)
52
+ rescue StandardError => e
53
+ Shipeasy::Logging.error "[shipeasy] Client#get_flag_detail('#{name}') failed — returning safe default: #{e.message}"
54
+ Shipeasy::Engine::FlagDetail.new(value: false, reason: Shipeasy::Engine::REASON_CLIENT_NOT_READY)
43
55
  end
44
56
 
45
57
  # Configs are not user-scoped, but exposed here for one-stop ergonomics.
46
58
  def get_config(name, decode = nil, default: nil)
47
59
  @engine.get_config(name, decode, default: default)
60
+ rescue StandardError => e
61
+ Shipeasy::Logging.error "[shipeasy] Client#get_config('#{name}') failed — returning default: #{e.message}"
62
+ default
63
+ end
64
+
65
+ # Assign the bound user within a universe: `client.universe("checkout").assign`.
66
+ # A universe is a mutual-exclusion pool — the unit lands in at most one
67
+ # experiment. Returns a reusable handle whose `assign` takes NO user arg (the
68
+ # user is bound at construction) and forwards the bound attributes to the
69
+ # engine. `assign` auto-logs a single deduped exposure when enrolled and
70
+ # returns an Eval::Assignment (never raises).
71
+ def universe(name)
72
+ BoundUniverseHandle.new(@engine, name, @attributes)
48
73
  end
49
74
 
50
- def get_experiment(name, default_params, decode = nil)
51
- @engine.get_experiment(name, @attributes, default_params, decode)
75
+ # Returned by Client#universe. Binds the universe name AND the client's
76
+ # already-resolved attributes, so `assign` needs no user argument.
77
+ class BoundUniverseHandle
78
+ def initialize(engine, name, attributes)
79
+ @engine = engine
80
+ @name = name
81
+ @attributes = attributes
82
+ end
83
+
84
+ def assign
85
+ @engine.assign_universe(@name, @attributes)
86
+ rescue StandardError => e
87
+ Shipeasy::Logging.error "[shipeasy] Client#universe('#{@name}').assign failed — returning not-enrolled: #{e.message}"
88
+ Shipeasy::SDK::Eval::Assignment.new(nil, nil, {})
89
+ end
52
90
  end
53
91
 
54
92
  # Killswitches are not user-scoped; forwarded straight to the engine.
55
93
  def get_killswitch(name, switch_key = nil)
56
94
  @engine.get_killswitch(name, switch_key)
95
+ rescue StandardError => e
96
+ Shipeasy::Logging.error "[shipeasy] Client#get_killswitch('#{name}') failed — returning false: #{e.message}"
97
+ false
57
98
  end
58
99
 
59
100
  def track(event_name, props = {})
60
101
  id = @attributes["user_id"] || @attributes["anonymous_id"]
61
102
  @engine.track(id, event_name, props)
62
- end
63
-
64
- def log_exposure(experiment_name)
65
- @engine.log_exposure(@attributes, experiment_name)
103
+ rescue StandardError => e
104
+ Shipeasy::Logging.error "[shipeasy] Client#track('#{event_name}') failed: #{e.message}"
105
+ nil
66
106
  end
67
107
  end
68
108
 
@@ -30,8 +30,22 @@ module Shipeasy
30
30
  # - private_attributes: attribute keys stripped from every outbound event
31
31
  # before it leaves the process (they still drive targeting locally).
32
32
  # - sticky_store: pin a user's experiment group across re-buckets.
33
+ # - disable_internal_error_reporting (default false): opt out of the
34
+ # SDK's self-monitoring channel. When the SDK's last-resort guard
35
+ # swallows one of its OWN internal errors it normally ships a structured
36
+ # see event to Shipeasy's own project (NOT yours) so the SDK team can
37
+ # track SDK bugs; set true to disable that entirely.
33
38
  attr_accessor :env, :disable_telemetry, :telemetry_url,
34
- :private_attributes, :sticky_store
39
+ :private_attributes, :sticky_store,
40
+ :disable_internal_error_reporting
41
+
42
+ # SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
43
+ # One of :silent, :error, :warn (default), :info, :debug (strings accepted
44
+ # and downcased; unknown falls back to :warn). Controls the stderr output of
45
+ # every caught-error diagnostic the SDK emits — the public runtime reads
46
+ # (get_flag/get_config/…) never raise, so this only tunes how loud they are
47
+ # about a recovered failure. Threaded into the global Engine by configure.
48
+ attr_accessor :log_level
35
49
 
36
50
  # Fetch lifecycle for the global engine `configure` builds:
37
51
  # - init (default true): fire a one-shot fetch fire-and-forget so the first
@@ -61,15 +75,17 @@ module Shipeasy
61
75
  :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
62
76
 
63
77
  def initialize
64
- @base_url = "https://edge.shipeasy.dev"
78
+ @base_url = "https://api.shipeasy.ai"
65
79
  @attributes = nil
66
80
  @init = true
67
81
  @poll = false
68
82
  @env = "prod"
69
83
  @disable_telemetry = false
84
+ @disable_internal_error_reporting = false
70
85
  @telemetry_url = nil
71
86
  @private_attributes = nil
72
87
  @sticky_store = nil
88
+ @log_level = :warn
73
89
 
74
90
  @profile = "default"
75
91
  @default_chunk = "index"
@@ -147,6 +163,8 @@ module Shipeasy
147
163
  telemetry_url: cfg.telemetry_url,
148
164
  private_attributes: cfg.private_attributes,
149
165
  sticky_store: cfg.sticky_store,
166
+ log_level: cfg.log_level,
167
+ disable_internal_error_reporting: cfg.disable_internal_error_reporting,
150
168
  )
151
169
  @engine = engine
152
170
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
@@ -155,13 +173,13 @@ module Shipeasy
155
173
  Thread.new do
156
174
  engine.init # initial fetch + background poll thread
157
175
  rescue => e
158
- warn "[shipeasy] configure(poll) background poll failed: #{e.message}"
176
+ Shipeasy::Logging.error "[shipeasy] configure(poll) background poll failed: #{e.message}"
159
177
  end
160
178
  elsif cfg.init
161
179
  Thread.new do
162
180
  engine.init_once
163
181
  rescue => e
164
- warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
182
+ Shipeasy::Logging.error "[shipeasy] configure() one-shot fetch failed: #{e.message}"
165
183
  end
166
184
  end
167
185
  engine
@@ -3,11 +3,13 @@ require "uri"
3
3
  require "json"
4
4
  require "thread"
5
5
  require "cgi"
6
+ require_relative "logging"
6
7
  require_relative "sdk/eval"
7
8
  require_relative "sdk/telemetry"
8
9
  require_relative "sdk/anon_id"
9
10
  require_relative "sdk/sticky_store"
10
11
  require_relative "sdk/see"
12
+ require_relative "sdk/internal_report"
11
13
 
12
14
  module Shipeasy
13
15
  # The heavyweight engine: owns the api key, HTTP transport, the blob cache,
@@ -28,12 +30,17 @@ module Shipeasy
28
30
  AnonId = Shipeasy::SDK::AnonId
29
31
  See = Shipeasy::SDK::See
30
32
 
31
- DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
33
+ DEFAULT_BASE_URL = "https://api.shipeasy.ai"
32
34
  # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
33
35
  # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
34
36
  DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
35
37
 
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)
38
+ def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil, log_level: nil, disable_internal_error_reporting: false)
39
+ # SDK-wide diagnostic verbosity. Set the leveled logger from the passed
40
+ # level (default :warn; unknown falls back to :warn). The logger is
41
+ # module-scoped, so the last-built engine wins — mirrors the TS SDK,
42
+ # where the last configure() sets the level.
43
+ Shipeasy::Logging.set_level(log_level || Shipeasy::Logging::DEFAULT_LEVEL)
37
44
  @api_key = api_key
38
45
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
39
46
  # Read-env tag. Used by telemetry below and stamped onto see() error
@@ -45,7 +52,7 @@ module Shipeasy
45
52
  # track(), where the listed keys are dropped from the props bag.
46
53
  @private_attributes = (private_attributes || []).map(&:to_s)
47
54
  # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
48
- # Threaded into get_experiment so an enrolled unit locks to its first
55
+ # Threaded into experiment eval so an enrolled unit locks to its first
49
56
  # assigned variant. Built-in: InMemoryStickyStore.
50
57
  @sticky_store = sticky_store
51
58
  # Test mode: no network, ever. init/init_once/track become no-ops and
@@ -82,6 +89,22 @@ module Shipeasy
82
89
  # see() structured error reporting. Per-process spam guard, bound here so
83
90
  # repeated reports of the same issue collapse to one send. See see.rb.
84
91
  @see_limiter = See::Limiter.new
92
+ # Auto-exposure dedup set: assign() logs a single exposure per
93
+ # (unit, experiment, group) so repeated assigns in one process don't spam
94
+ # /collect. Bounded — cleared when it grows past ~5000 keys.
95
+ @exposure_seen = {}
96
+ # Self-monitoring channel: when safe_run swallows one of the SDK's OWN
97
+ # internal errors, it also ships a see event to Shipeasy's own project
98
+ # (a baked-in destination, distinct from the consumer's see() path) so
99
+ # the SDK team can track SDK bugs across every app. Fire-and-forget,
100
+ # never raises. On by default; forced off in test mode (no network) and
101
+ # opt-out-able via disable_internal_error_reporting. Module-scoped, so
102
+ # the last-built engine wins — mirrors set_level / the TS reference.
103
+ Shipeasy::SDK::InternalReport.set_context(
104
+ side: "server",
105
+ sdk_version: Shipeasy::SDK::VERSION,
106
+ enabled: !test_mode && !disable_internal_error_reporting,
107
+ )
85
108
  # Register as the default client backing the module-level Shipeasy::SDK
86
109
  # .see/.see_violation funcs (last constructed wins — the server-SDK
87
110
  # analog of TS's shipeasy({key}) configure call).
@@ -205,6 +228,12 @@ module Shipeasy
205
228
  # Evaluate a flag and return why. Telemetry ("gate" beacon) is emitted
206
229
  # exactly once here (steps 2–5), never on the OVERRIDE short-circuit.
207
230
  def get_flag_detail(name, user)
231
+ safe_run("get_flag_detail('#{name}')", FlagDetail.new(value: false, reason: REASON_CLIENT_NOT_READY)) do
232
+ get_flag_detail_inner(name, user)
233
+ end
234
+ end
235
+
236
+ def get_flag_detail_inner(name, user)
208
237
  key = name.to_s
209
238
 
210
239
  # 1. Override short-circuits before any telemetry (mirrors get_config).
@@ -233,66 +262,106 @@ module Shipeasy
233
262
  end
234
263
 
235
264
  def get_flag(name, user, default: false)
236
- detail = get_flag_detail(name, user)
237
- if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
238
- default
239
- else
240
- detail.value
265
+ safe_run("get_flag('#{name}')", default) do
266
+ detail = get_flag_detail(name, user)
267
+ if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
268
+ default
269
+ else
270
+ detail.value
271
+ end
241
272
  end
242
273
  end
243
274
 
244
275
  def get_config(name, decode = nil, default: nil)
245
- key = name.to_s
246
- has_override, override = @mutex.synchronize do
247
- [@config_overrides.key?(key), @config_overrides[key]]
248
- end
249
- if has_override
250
- return decode ? decode.call(override) : override
251
- end
276
+ safe_run("get_config('#{name}')", default) do
277
+ key = name.to_s
278
+ has_override, override = @mutex.synchronize do
279
+ [@config_overrides.key?(key), @config_overrides[key]]
280
+ end
281
+ if has_override
282
+ begin
283
+ next(decode ? decode.call(override) : override)
284
+ rescue => e
285
+ Shipeasy::Logging.warn "[shipeasy] get_config('#{name}') decode failed: #{e.message}"
286
+ next default
287
+ end
288
+ end
252
289
 
253
- @telemetry.emit("config", name)
254
- entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
255
- return default unless entry
256
- value = entry["value"]
257
- decode ? decode.call(value) : value
290
+ @telemetry.emit("config", name)
291
+ entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
292
+ next default unless entry
293
+ value = entry["value"]
294
+ begin
295
+ decode ? decode.call(value) : value
296
+ rescue => e
297
+ Shipeasy::Logging.warn "[shipeasy] get_config('#{name}') decode failed: #{e.message}"
298
+ default
299
+ end
300
+ end
258
301
  end
259
302
 
260
- def get_experiment(name, user, default_params, decode = nil)
261
- key = name.to_s
262
- override = @mutex.synchronize { @exp_overrides[key] }
263
- if override
264
- params = override[:params]
265
- params = decode.call(params) if decode
266
- return Eval::ExperimentResult.new(
267
- in_experiment: true,
268
- group: override[:group],
269
- params: params,
303
+ # Assign +user+ within +universe_name+. A universe is a mutual-exclusion
304
+ # pool, so a unit lands in AT MOST ONE experiment; the returned
305
+ # Eval::Assignment exposes the variant + resolved params and auto-logs a
306
+ # single exposure when enrolled. An un-enrolled unit still resolves get()
307
+ # to the universe defaults. Never raises. This is the sole experiment read
308
+ # path — there is no get_experiment (a caller asks a universe, not an
309
+ # experiment). Internal: the public surface is universe(name).assign(user).
310
+ def assign_universe(universe_name, user)
311
+ empty = Eval::Assignment.new(nil, nil, {})
312
+ safe_run("assign_universe('#{universe_name}')", empty) do
313
+ @telemetry.emit("experiment", universe_name)
314
+ u = with_anon_id(user)
315
+ flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
316
+
317
+ universe = exps_blob&.dig("universes", universe_name.to_s)
318
+ param_defaults = Eval.param_defaults_from_schema(
319
+ universe && (universe["param_schema"] || universe[:param_schema])
270
320
  )
321
+ not_enrolled = Eval::Assignment.new(nil, nil, param_defaults || {})
322
+ next not_enrolled unless exps_blob
323
+
324
+ # Candidate running experiments in this universe. Deterministic order:
325
+ # pool-slice offset asc (slices are disjoint so <=1 matches under
326
+ # pooling), then name. A universe-held-out or unallocated unit falls
327
+ # through to the defaults-only handle.
328
+ candidates = (exps_blob["experiments"] || {}).select do |_name, exp|
329
+ exp["universe"] == universe_name.to_s && exp["status"] == "running"
330
+ end.sort_by { |name, exp| [(exp["poolOffsetBp"] || 0), name] }
331
+
332
+ landed = nil
333
+ candidates.each do |name, exp|
334
+ result = eval_experiment(name, exp, u, flags_blob, exps_blob)
335
+ next unless result.in_experiment
336
+ post_exposure(u, name, result.group)
337
+ landed = Eval::Assignment.new(name, result.group, result.params || {})
338
+ break
339
+ # not enrolled: try the next candidate — under pooling only one slice
340
+ # can match, so the loop lands on the winner (or falls through).
341
+ end
342
+
343
+ landed || not_enrolled
271
344
  end
345
+ end
272
346
 
273
- @telemetry.emit("experiment", name)
274
- flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
275
- exp = exps_blob&.dig("experiments", name)
276
- result = Eval.eval_experiment(
277
- exp, flags_blob, exps_blob, with_anon_id(user),
278
- exp_name: name.to_s, sticky_store: @sticky_store,
279
- )
280
- result.params ||= default_params
347
+ # A reusable handle bound to one universe. +assign(user)+ picks the <=1
348
+ # experiment the unit is pooled into and auto-logs a single exposure. See
349
+ # assign_universe.
350
+ def universe(name)
351
+ UniverseHandle.new(self, name)
352
+ end
281
353
 
282
- if result.in_experiment && decode
283
- begin
284
- result = Eval::ExperimentResult.new(
285
- in_experiment: true,
286
- group: result.group,
287
- params: decode.call(result.params),
288
- )
289
- rescue => e
290
- warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
291
- return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
292
- end
354
+ # Returned by Engine#universe. Binds a universe name so callers can reuse
355
+ # the handle: `engine.universe("checkout").assign(user)`.
356
+ class UniverseHandle
357
+ def initialize(engine, name)
358
+ @engine = engine
359
+ @name = name
293
360
  end
294
361
 
295
- result
362
+ def assign(user)
363
+ @engine.assign_universe(@name, user)
364
+ end
296
365
  end
297
366
 
298
367
  # Public hook for the bound Shipeasy::Client: normalise an attribute hash
@@ -309,16 +378,18 @@ module Shipeasy
309
378
  # value (so an unconfigured key behaves exactly like the no-key call).
310
379
  # Unknown killswitches return false. Not user-scoped.
311
380
  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
381
+ safe_run("get_killswitch('#{name}')", false) do
382
+ @telemetry.emit("ks", name)
383
+ ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
384
+ next false unless ks
385
+ unless switch_key.nil?
386
+ switches = ks["switches"] || {}
387
+ key = switch_key.to_s
388
+ next Eval.enabled?(switches[key]) if switches.key?(key)
389
+ # key not configured → fall through to the top-level value
390
+ end
391
+ Eval.enabled?(ks["killed"])
320
392
  end
321
- Eval.enabled?(ks["killed"])
322
393
  end
323
394
 
324
395
  # Batch-evaluate every loaded gate, config and experiment for +user+ into
@@ -329,9 +400,8 @@ module Shipeasy
329
400
  # for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
330
401
  def evaluate(user)
331
402
  u = with_anon_id(user)
332
- flags_blob, exps_blob, flag_ov, config_ov, exp_ov, sticky = @mutex.synchronize do
333
- [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup,
334
- @exp_overrides.dup, @sticky_store]
403
+ flags_blob, exps_blob, flag_ov, config_ov = @mutex.synchronize do
404
+ [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup]
335
405
  end
336
406
 
337
407
  flags = {}
@@ -344,18 +414,30 @@ module Shipeasy
344
414
  configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
345
415
  end
346
416
 
417
+ # Per-experiment result carries the universe name; a top-level universes
418
+ # map exposes each universe's param defaults so the client can resolve
419
+ # universe(name).get() to a default even when the unit is not enrolled.
347
420
  experiments = {}
421
+ universes = {}
348
422
  (exps_blob&.dig("experiments") || {}).each do |name, exp|
349
- if exp_ov.key?(name)
350
- ov = exp_ov[name]
351
- experiments[name] = { "inExperiment" => true, "group" => ov[:group], "params" => ov[:params] }
352
- next
423
+ uni_name = exp["universe"]
424
+ unless universes.key?(uni_name)
425
+ uni = exps_blob&.dig("universes", uni_name)
426
+ universes[uni_name] = {
427
+ "defaults" => Eval.param_defaults_from_schema(uni && (uni["param_schema"] || uni[:param_schema])) || {},
428
+ }
353
429
  end
354
- r = Eval.eval_experiment(exp, flags_blob, exps_blob, u, exp_name: name, sticky_store: sticky)
355
- experiments[name] = { "inExperiment" => r.in_experiment, "group" => r.group, "params" => r.params }
430
+ r = eval_experiment(name, exp, u, flags_blob, exps_blob, emit_telemetry: false)
431
+ experiments[name] = {
432
+ "inExperiment" => r.in_experiment,
433
+ "group" => r.in_experiment ? r.group : "control",
434
+ "params" => r.in_experiment ? (r.params || {}) : {},
435
+ "universe" => uni_name,
436
+ }
356
437
  end
357
438
 
358
- { "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
439
+ { "flags" => flags, "configs" => configs, "experiments" => experiments,
440
+ "killswitches" => {}, "universes" => universes }
359
441
  end
360
442
 
361
443
  # Return the cross-platform SSR bootstrap <script> tag for a request:
@@ -387,55 +469,27 @@ module Shipeasy
387
469
  end
388
470
 
389
471
  def track(user_id, event_name, props = {})
390
- return if @test_mode
391
-
392
- safe_props = strip_private(props)
393
-
394
- payload = JSON.generate({
395
- events: [{
396
- type: "metric",
397
- event_name: event_name,
398
- user_id: user_id.to_s,
399
- ts: (Time.now.to_f * 1000).to_i,
400
- **(safe_props.empty? ? {} : { properties: safe_props }),
401
- }],
402
- })
403
-
404
- Thread.new do
405
- post("/collect", payload)
406
- rescue => e
407
- warn "[shipeasy] track failed: #{e.message}"
408
- end
409
- end
410
-
411
- # Emit an exposure event for an experiment at the server-side decision
412
- # point (parity with the browser's auto-exposure). The server is stateless
413
- # and never auto-logs, so call this when you actually present the
414
- # treatment. Re-evaluates the experiment for the user (a bare user_id
415
- # string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
416
- # exposure to /collect. No-op in test mode or when the user isn't enrolled.
417
- def log_exposure(user_or_user_id, experiment_name)
418
- return if @test_mode
419
-
420
- user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
421
- result = get_experiment(experiment_name, user, {})
422
- return unless result.in_experiment
423
-
424
- u = user.transform_keys(&:to_s)
425
- payload = JSON.generate({
426
- events: [{
427
- type: "exposure",
428
- experiment: experiment_name.to_s,
429
- group: result.group,
430
- user_id: (u["user_id"] || u["anonymous_id"]).to_s,
431
- ts: (Time.now.to_f * 1000).to_i,
432
- }],
433
- })
434
-
435
- Thread.new do
436
- post("/collect", payload)
437
- rescue => e
438
- warn "[shipeasy] log_exposure failed: #{e.message}"
472
+ safe_run("track('#{event_name}')", nil) do
473
+ next if @test_mode
474
+
475
+ safe_props = strip_private(props)
476
+
477
+ payload = JSON.generate({
478
+ events: [{
479
+ type: "metric",
480
+ event_name: event_name,
481
+ user_id: user_id.to_s,
482
+ ts: (Time.now.to_f * 1000).to_i,
483
+ **(safe_props.empty? ? {} : { properties: safe_props }),
484
+ }],
485
+ })
486
+
487
+ Thread.new do
488
+ post("/collect", payload)
489
+ rescue => e
490
+ Shipeasy::Logging.warn "[shipeasy] track failed: #{e.message}"
491
+ end
492
+ nil
439
493
  end
440
494
  end
441
495
 
@@ -466,6 +520,97 @@ module Shipeasy
466
520
 
467
521
  private
468
522
 
523
+ # Last-resort guard that makes a public RUNTIME method (get_flag /
524
+ # get_config / assign_universe / get_killswitch / track) unable to raise
525
+ # into product code, even if an internal invariant is
526
+ # violated. Runs the block; on any StandardError it logs at :error and
527
+ # returns +fallback+ (the method's documented safe default). +label+ names
528
+ # the method for the log line.
529
+ def safe_run(label, fallback)
530
+ yield
531
+ rescue StandardError => e
532
+ Shipeasy::Logging.error "[shipeasy] #{label} failed — returning safe default: #{e.message}"
533
+ # A caught error here is by definition "on our end" — an internal SDK
534
+ # failure, not the caller's — so in addition to logging locally it is
535
+ # reported to Shipeasy's own project via the self-monitoring channel
536
+ # (fire-and-forget, never raises). The label's stable stem (e.g.
537
+ # "get_flag" from "get_flag('new_checkout')") is the issue subject, so
538
+ # occurrences of the same bug dedupe regardless of the resource name.
539
+ Shipeasy::SDK::InternalReport.report(internal_subject(label), e)
540
+ fallback
541
+ end
542
+
543
+ # The stable subject for an internal-error report: strip the variable
544
+ # "('resource')" argument off a safe_run label so the fingerprint carries
545
+ # no variable data and identical bugs dedupe into one issue.
546
+ def internal_subject(label)
547
+ label.to_s.sub(/\(.*\)\z/, "")
548
+ end
549
+
550
+ # Evaluate one experiment by name for +user+ — override -> full classify
551
+ # pipeline (targeting -> universe holdout -> holdout gate -> sticky ->
552
+ # allocation -> group), merging the universe defaults under the assigned
553
+ # variant. Returns an Eval::ExperimentResult. Reused by assign_universe and
554
+ # the SSR evaluate() bootstrap (keyed by experiment name). Emits the
555
+ # per-experiment telemetry beacon exactly once (never on the override
556
+ # short-circuit), unless +emit_telemetry+ is false (the batch evaluate()
557
+ # path is not a per-experiment exposure). +user+ is expected pre-normalised
558
+ # (with_anon_id).
559
+ def eval_experiment(name, exp, user, flags_blob, exps_blob, emit_telemetry: true)
560
+ key = name.to_s
561
+ override = @mutex.synchronize { @exp_overrides[key] }
562
+ if override
563
+ universe = exps_blob&.dig("universes", exp && exp["universe"])
564
+ param_defaults = Eval.param_defaults_from_schema(
565
+ universe && (universe["param_schema"] || universe[:param_schema])
566
+ )
567
+ return Eval::ExperimentResult.new(
568
+ in_experiment: true,
569
+ group: override[:group],
570
+ params: Eval.merge_params(param_defaults, override[:params]),
571
+ )
572
+ end
573
+
574
+ @telemetry.emit("experiment", name) if emit_telemetry
575
+ Eval.eval_experiment(
576
+ exp, flags_blob, exps_blob, user,
577
+ exp_name: key, sticky_store: @sticky_store,
578
+ )
579
+ end
580
+
581
+ # POST a single exposure for an enrolled (user, experiment, group). Deduped
582
+ # per process (bounded set) so repeated assign() calls in one server don't
583
+ # spam /collect. Fire-and-forget; no-op in test mode. This is how
584
+ # assign_universe auto-logs — the browser's auto-exposure parity for SSR.
585
+ def post_exposure(user, experiment, group)
586
+ return if @test_mode
587
+ u = user.transform_keys(&:to_s)
588
+ uid = u["user_id"] || u["anonymous_id"]
589
+ dedup_key = "#{uid}:#{experiment}:#{group}"
590
+ @mutex.synchronize do
591
+ return if @exposure_seen.key?(dedup_key)
592
+ @exposure_seen.clear if @exposure_seen.size > 5000
593
+ @exposure_seen[dedup_key] = true
594
+ end
595
+
596
+ payload = JSON.generate({
597
+ events: [{
598
+ type: "exposure",
599
+ experiment: experiment.to_s,
600
+ group: group,
601
+ user_id: uid.to_s,
602
+ ts: (Time.now.to_f * 1000).to_i,
603
+ }],
604
+ })
605
+
606
+ Thread.new do
607
+ post("/collect", payload)
608
+ rescue => e
609
+ Shipeasy::Logging.warn "[shipeasy] exposure send failed: #{e.message}"
610
+ end
611
+ nil
612
+ end
613
+
469
614
  # Build the wire event and fire-and-forget POST it to /collect. No-op in
470
615
  # test mode (mirrors track). Spam-guarded. Never raises into caller code.
471
616
  def dispatch_see(built)
@@ -485,10 +630,10 @@ module Shipeasy
485
630
  Thread.new do
486
631
  post("/collect", payload)
487
632
  rescue => e
488
- warn "[shipeasy] see() send failed: #{e.message}"
633
+ Shipeasy::Logging.warn "[shipeasy] see() send failed: #{e.message}"
489
634
  end
490
635
  rescue => e
491
- warn "[shipeasy] see() failed: #{e.message}"
636
+ Shipeasy::Logging.error "[shipeasy] see() failed: #{e.message}"
492
637
  end
493
638
 
494
639
  # Drop caller-marked private attributes from an outbound props bag. Handles
@@ -519,7 +664,7 @@ module Shipeasy
519
664
  begin
520
665
  listener.call
521
666
  rescue => e
522
- warn "[shipeasy] on_change listener raised: #{e.message}"
667
+ Shipeasy::Logging.warn "[shipeasy] on_change listener raised: #{e.message}"
523
668
  end
524
669
  end
525
670
  end
@@ -557,7 +702,7 @@ module Shipeasy
557
702
  begin
558
703
  fetch_all
559
704
  rescue => e
560
- warn "[shipeasy] background poll failed: #{e.message}"
705
+ Shipeasy::Logging.error "[shipeasy] background poll failed: #{e.message}"
561
706
  end
562
707
  end
563
708
  end
@@ -0,0 +1,79 @@
1
+ # Leveled logger shared by the whole gem.
2
+ #
3
+ # Every diagnostic the SDK emits from a *caught* error goes through here, so a
4
+ # single `log_level` config option (default :warn) controls the SDK's stderr
5
+ # output. The contract for the SDK's public RUNTIME methods (get_flag,
6
+ # get_config, universe(...).assign, get_killswitch, track, see, …) is
7
+ # that they NEVER raise into product code — so logging itself is best-effort
8
+ # too: a broken/throwing $stderr can never take down a flag read.
9
+ #
10
+ # Level ordering (a message at level L is emitted iff the configured level is at
11
+ # least as verbose as L):
12
+ # silent < error < warn < info < debug
13
+ # :warn (the default) therefore emits `error` + `warn` and suppresses the
14
+ # informational `info` / `debug` chatter.
15
+
16
+ module Shipeasy
17
+ module Logging
18
+ # All accepted levels, in increasing verbosity.
19
+ LEVELS = %i[silent error warn info debug].freeze
20
+
21
+ RANK = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }.freeze
22
+
23
+ DEFAULT_LEVEL = :warn
24
+
25
+ class << self
26
+ # Set the SDK-wide log level. Accepts a symbol or string (case-insensitive);
27
+ # an unrecognised value falls back to the default :warn so a bad config value
28
+ # can never silence a genuine error unexpectedly.
29
+ def set_level(level)
30
+ @level = normalize(level)
31
+ end
32
+
33
+ # The current SDK-wide log level (defaults to :warn until set).
34
+ def level
35
+ @level || DEFAULT_LEVEL
36
+ end
37
+
38
+ # Normalise any input to a known level symbol; unknown → DEFAULT_LEVEL.
39
+ def normalize(level)
40
+ sym =
41
+ case level
42
+ when Symbol then level.to_s.downcase.to_sym
43
+ when String then level.downcase.to_sym
44
+ else nil
45
+ end
46
+ RANK.key?(sym) ? sym : DEFAULT_LEVEL
47
+ end
48
+
49
+ def error(msg)
50
+ emit(:error, msg)
51
+ end
52
+
53
+ def warn(msg)
54
+ emit(:warn, msg)
55
+ end
56
+
57
+ def info(msg)
58
+ emit(:info, msg)
59
+ end
60
+
61
+ def debug(msg)
62
+ emit(:debug, msg)
63
+ end
64
+
65
+ private
66
+
67
+ # Gate on the configured level, then write to $stderr exactly as the old
68
+ # bare `warn "[shipeasy] …"` calls did (Kernel#warn → $stderr). Never
69
+ # raises: a broken stream is swallowed.
70
+ def emit(msg_level, msg)
71
+ return if RANK[level] < RANK[msg_level]
72
+
73
+ Kernel.warn(msg)
74
+ rescue StandardError
75
+ # logging must never raise into product code
76
+ end
77
+ end
78
+ end
79
+ end
@@ -92,6 +92,26 @@ module Shipeasy
92
92
 
93
93
  ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
94
94
 
95
+ # Flatten a universe param schema (`[{ "name", "type", "default" }, ...]`)
96
+ # to a plain `name => default` map — the defaults `assign()` layers under a
97
+ # variant's override map. Returns nil for a null/empty schema so the merge
98
+ # short-circuits. Mirrors the TS reference / @shipeasy/core.
99
+ def self.param_defaults_from_schema(schema)
100
+ return nil if schema.nil? || schema.empty?
101
+ schema.each_with_object({}) do |p, h|
102
+ name = p["name"] || p[:name]
103
+ h[name] = p.key?("default") ? p["default"] : p[:default]
104
+ end
105
+ end
106
+
107
+ # `universeDefaults ⊕ variantOverride` — a variant inherits every universe
108
+ # default it doesn't explicitly override. A nil defaults map short-circuits
109
+ # to the variant params (dup'd) alone.
110
+ def self.merge_params(param_defaults, group_params)
111
+ gp = group_params || {}
112
+ param_defaults ? param_defaults.merge(gp) : gp.dup
113
+ end
114
+
95
115
  # exp_name + sticky_store are optional so existing callers stay deterministic.
96
116
  # When a sticky_store is passed, an enrolled unit whose stored salt prefix
97
117
  # still matches skips the allocation gate (so a shrinking allocation keeps
@@ -99,8 +119,23 @@ module Shipeasy
99
119
  # pick is persisted via store.set; a salt mismatch / missing stored group
100
120
  # falls through to re-bucket + overwrite. Mirrors the TS reference
101
121
  # (doc 20 §2). exp_name is the key under which the entry is stored.
122
+ #
123
+ # Pooling / holdout-gate / reserved-headroom / universe param-default merge
124
+ # (doc 20 §B) are layered in here so both the low-level parity path and the
125
+ # universe assign() path share ONE classify. The added steps are all guarded
126
+ # by presence checks, so a legacy blob (no hashVersion/pool/reserved) behaves
127
+ # exactly as before.
102
128
  def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
129
+ universe_name = exp && exp["universe"]
130
+ universe = exps_blob&.dig("universes", universe_name)
131
+ param_defaults = param_defaults_from_schema(universe && (universe["param_schema"] || universe[:param_schema]))
132
+
103
133
  not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
134
+ as_group = lambda do |g|
135
+ ExperimentResult.new(
136
+ in_experiment: true, group: g["name"], params: merge_params(param_defaults, g["params"]),
137
+ )
138
+ end
104
139
 
105
140
  return not_in unless exp && exp["status"] == "running"
106
141
 
@@ -114,12 +149,22 @@ module Shipeasy
114
149
  uid = pick_identifier(user, bucket_by)
115
150
  return not_in unless uid
116
151
 
117
- universe_name = exp["universe"]
118
- universe = exps_blob&.dig("universes", universe_name)
152
+ # One segment in the universe's shared [0, 10000) hash space. The holdout
153
+ # carve-out AND every experiment's pool slice are disjoint ranges of THIS
154
+ # segment — that's what makes "held out / taken / free" a real partition.
155
+ universe_seg = murmur3("#{universe_name}:#{uid}") % 10000
156
+
119
157
  holdout = universe&.dig("holdout_range")
120
158
  if holdout
121
- seg = murmur3("#{universe_name}:#{uid}") % 10000
122
- return not_in if seg >= holdout[0] && seg <= holdout[1]
159
+ return not_in if universe_seg >= holdout[0] && universe_seg <= holdout[1]
160
+ end
161
+
162
+ # Holdout gate: a passing gate holds the unit out of every experiment in
163
+ # the universe (mirrors the universe carve-out, but gate-driven).
164
+ holdout_gate = exp["holdoutGate"]
165
+ if holdout_gate && !holdout_gate.to_s.empty?
166
+ gate = flags_blob&.dig("gates", holdout_gate)
167
+ return not_in if gate && eval_gate(gate, user)
123
168
  end
124
169
 
125
170
  salt = exp["salt"]
@@ -134,24 +179,87 @@ module Shipeasy
134
179
  entry = (sticky_store.get(uid) || {})[exp_name]
135
180
  if entry && entry["s"] == salt8
136
181
  g = groups.find { |x| x["name"] == entry["g"] }
137
- return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"]) if g
182
+ return as_group.call(g) if g
138
183
  end
139
184
  end
140
185
 
141
- return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
186
+ # Allocation. Pooled (hashVersion >= 2 with a slice) gives real mutual
187
+ # exclusion: the unit's universe segment must fall in the claimed range.
188
+ # Legacy falls back to an independent per-experiment salt so siblings
189
+ # overlap freely (the existing parity path).
190
+ hash_version = exp["hashVersion"] || exp[:hashVersion] || 1
191
+ pool_offset = exp["poolOffsetBp"] || exp[:poolOffsetBp]
192
+ pool_size = exp["poolSizeBp"] || exp[:poolSizeBp]
193
+ pooled = hash_version >= 2 && !pool_offset.nil? && !pool_size.nil? && pool_size > 0
194
+ if pooled
195
+ lo = pool_offset
196
+ hi = pool_offset + pool_size
197
+ return not_in if universe_seg < lo || universe_seg >= hi
198
+ else
199
+ return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
200
+ end
142
201
 
202
+ # Group split over [0, usable) where usable = 10000 - reserved; a unit in
203
+ # the reserved tail is left unassigned so an appended variant can absorb it.
204
+ reserved = (exp["reservedHeadroomBp"] || exp[:reservedHeadroomBp] || 0)
205
+ reserved = 0 if reserved < 0
206
+ reserved = 10000 if reserved > 10000
207
+ usable = 10000 - reserved
143
208
  group_hash = murmur3("#{salt}:group:#{uid}") % 10000
209
+ return not_in if group_hash >= usable
210
+
144
211
  cumulative = 0
145
212
  groups.each_with_index do |g, i|
146
213
  cumulative += g["weight"]
147
214
  if group_hash < cumulative || i == groups.length - 1
148
215
  sticky_store.set(uid, exp_name, { "g" => g["name"], "s" => salt8 }) if sticky_store && exp_name
149
- return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
216
+ return as_group.call(g)
150
217
  end
151
218
  end
152
219
 
153
220
  not_in
154
221
  end
222
+
223
+ # The result of `universe(name).assign(user)` — a unit's standing in a
224
+ # universe (a mutual-exclusion pool, so it lands in at most one experiment).
225
+ # Never raises: an un-enrolled unit still resolves `get` to the universe
226
+ # defaults (or the caller's fallback). Reading is side-effect free — the
227
+ # single exposure is logged once by assign() when the unit is enrolled.
228
+ class Assignment
229
+ # The experiment the unit landed in, or nil when not enrolled.
230
+ attr_reader :name
231
+ # The assigned variant/group name, or nil when not enrolled.
232
+ attr_reader :group
233
+
234
+ # +params+ is already merged (universeDefaults ⊕ variantOverride) when
235
+ # enrolled; defaults-only (or {}) when not.
236
+ def initialize(name, group, params)
237
+ @name = name
238
+ @group = group
239
+ @params = params || {}
240
+ end
241
+
242
+ # True iff the unit is enrolled in an experiment in this universe.
243
+ def enrolled?
244
+ !@group.nil?
245
+ end
246
+
247
+ # Read a resolved param: the assigned variant's override, else the
248
+ # universe default, else +fallback+. Works even when not enrolled (the
249
+ # variant layer is absent, so you get universeDefault ?? fallback).
250
+ # Looks up both string and symbol keys.
251
+ def get(field, fallback = nil)
252
+ if @params.key?(field)
253
+ @params[field]
254
+ elsif field.respond_to?(:to_s) && @params.key?(field.to_s)
255
+ @params[field.to_s]
256
+ elsif field.respond_to?(:to_sym) && @params.key?(field.to_sym)
257
+ @params[field.to_sym]
258
+ else
259
+ fallback
260
+ end
261
+ end
262
+ end
155
263
  end
156
264
  end
157
265
  end
@@ -0,0 +1,154 @@
1
+ # Internal self-monitoring channel — SDK bugs that are "on our end".
2
+ #
3
+ # When the SDK swallows one of its OWN internal errors (the `Engine#safe_run`
4
+ # last-resort guard in engine.rb, which keeps a get_flag/get_config/… from
5
+ # raising into product code even when an internal invariant is violated), it
6
+ # ALSO ships a structured see event here — to Shipeasy's OWN project, NOT the
7
+ # consumer's — so the SDK team can track SDK-internal failures across every app
8
+ # the SDK runs in.
9
+ #
10
+ # This is deliberately distinct from the customer-facing `see()` path
11
+ # (Shipeasy::SDK.see / Engine#see), which authenticates with the consumer's key
12
+ # and lands in the consumer's dashboard. Internal errors must never pollute a
13
+ # customer's Errors tab, and the SDK team must see them centrally — so this
14
+ # channel has its own baked-in destination + credential.
15
+ #
16
+ # Guarantees (identical to telemetry/see): fire-and-forget, never blocks, never
17
+ # raises into product code, deduped/rate-limited. A failed send is swallowed
18
+ # silently — it must never log (that would risk recursion through safe_run).
19
+
20
+ require "net/http"
21
+ require "uri"
22
+ require "json"
23
+ require "thread"
24
+ require_relative "see"
25
+ require_relative "version"
26
+
27
+ module Shipeasy
28
+ module SDK
29
+ module InternalReport
30
+ # ---- Baked-in destination ----
31
+ #
32
+ # The main Shipeasy project (`.shipeasy` project_id
33
+ # e976b15e-3ccc-44d3-821d-87f06d5a0e43). The credential is a PUBLIC client
34
+ # key — the same class of credential already embedded verbatim in every
35
+ # browser bundle that ships the client SDK, and mirroring how the CLI bakes
36
+ # Shipeasy's own public key for setup-bug self-reporting — so baking it into
37
+ # the published gem is safe. `/collect` treats it as a write-only ingest
38
+ # key; it grants no read access. The canonical ingest host is
39
+ # api.shipeasy.ai (the SDK default base_url), which routes /collect to the
40
+ # edge worker.
41
+ INGEST_URL = "https://api.shipeasy.ai/collect".freeze
42
+
43
+ # Sentinel used until the real key is minted + baked. While INGEST_KEY is
44
+ # still the placeholder the channel stays fully inert (see .report), so a
45
+ # gem that ships before the key is provisioned never fires doomed requests.
46
+ # Mint the key with:
47
+ # shipeasy keys create --type client --env prod \
48
+ # --name "SDK internal error self-reporting" --scopes events:write
49
+ # then replace the INGEST_KEY assignment below with the returned value.
50
+ PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY".freeze
51
+
52
+ # The baked-in ingest credential. Swap the placeholder for the real minted
53
+ # key here (this is the ONLY line to change when the key is provisioned).
54
+ INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5"
55
+
56
+ # Stable consequence. The `label` (the safe_run operation name, e.g.
57
+ # "flags.get") is the subject; the outcome is fixed. Both are constant per
58
+ # operation — no variable data — so occurrences of the same internal bug
59
+ # fold into one issue on our dashboard (fingerprint = error_type +
60
+ # normalized message + top stack + subject|outcome). `sdk` marks which
61
+ # language SDK reported it.
62
+ OUTCOME = "returned a safe default".freeze
63
+ SDK_ID = "ruby".freeze
64
+
65
+ class << self
66
+ # True once a real key has been baked in (not the placeholder sentinel).
67
+ def key_configured?(key = @ingest_key || INGEST_KEY)
68
+ !key.nil? && !key.empty? && key != PLACEHOLDER_KEY
69
+ end
70
+
71
+ # Wire the self-monitoring channel. Called from Engine#initialize with the
72
+ # bundle's side + version. `enabled` defaults on; it is forced off in test
73
+ # mode (no network) and when the caller opts out via
74
+ # disable_internal_error_reporting.
75
+ def set_context(side:, sdk_version:, enabled: true)
76
+ @side = side
77
+ @sdk_version = sdk_version
78
+ @enabled = enabled != false
79
+ @limiter ||= See::Limiter.new
80
+ end
81
+
82
+ # Report an SDK-internal error to Shipeasy's own project. Called from
83
+ # Engine#safe_run's rescue. `label` is the swallowed operation (e.g.
84
+ # "flags.get") and becomes the stable issue subject. Never raises.
85
+ def report(label, err)
86
+ return unless @enabled
87
+ key = @ingest_key || INGEST_KEY
88
+ return unless key_configured?(key)
89
+
90
+ ev = See.build_event(
91
+ err,
92
+ label.to_s,
93
+ OUTCOME,
94
+ { "sdk" => SDK_ID },
95
+ sdk_version: @sdk_version || Shipeasy::SDK::VERSION,
96
+ env: nil,
97
+ )
98
+ # Internal reports carry only the SDK-side context — never a consumer's
99
+ # env or url — so a customer's config can't leak into our project.
100
+ ev["side"] = @side || "server"
101
+
102
+ limiter = (@limiter ||= See::Limiter.new)
103
+ return unless limiter.should_send?(ev)
104
+
105
+ send_event(key, JSON.generate({ events: [ev] }))
106
+ rescue StandardError
107
+ # Self-reporting must never raise into product code.
108
+ nil
109
+ end
110
+
111
+ private
112
+
113
+ # Fire-and-forget POST to the baked-in ingest on a background thread.
114
+ # Isolated so specs can intercept it without real network. A failed send
115
+ # is swallowed silently — it must never log (that would risk recursion
116
+ # through safe_run).
117
+ def send_event(key, body)
118
+ Thread.new do
119
+ begin
120
+ uri = URI.parse(INGEST_URL)
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = (uri.scheme == "https")
123
+ http.open_timeout = 2
124
+ http.read_timeout = 2
125
+ http.post(uri.request_uri, body, { "X-SDK-Key" => key, "Content-Type" => "text/plain" })
126
+ rescue StandardError
127
+ # self-reporting must never surface a network error
128
+ end
129
+ end
130
+ end
131
+
132
+ # ---- Test seams ----
133
+
134
+ public
135
+
136
+ # Reset module state (context + rate limiter + key override) so a spec
137
+ # starts from a clean, inert channel.
138
+ def reset_for_test!
139
+ @side = nil
140
+ @sdk_version = nil
141
+ @enabled = nil
142
+ @limiter = See::Limiter.new
143
+ @ingest_key = nil
144
+ end
145
+
146
+ # Stand in a real-looking key so specs can exercise the send path without
147
+ # the (deliberately inert) placeholder blocking it.
148
+ def set_ingest_key_for_test(key)
149
+ @ingest_key = key
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "2.3.1"
3
+ VERSION = "3.0.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require_relative "shipeasy/sdk/version"
2
+ require_relative "shipeasy/logging"
2
3
  require_relative "shipeasy/config"
3
4
  require_relative "shipeasy/sdk/murmur3"
4
5
  require_relative "shipeasy/sdk/eval"
@@ -54,7 +55,7 @@ module Shipeasy
54
55
  def self.see(problem)
55
56
  client = default_client
56
57
  if client.nil?
57
- warn "[shipeasy] see() called before a client was created — error dropped"
58
+ Shipeasy::Logging.warn "[shipeasy] see() called before a client was created — error dropped"
58
59
  return See::NullChain.new
59
60
  end
60
61
  client.see(problem)
@@ -64,7 +65,7 @@ module Shipeasy
64
65
  def self.see_violation(name)
65
66
  client = default_client
66
67
  if client.nil?
67
- warn "[shipeasy] see_violation() called before a client was created — error dropped"
68
+ Shipeasy::Logging.warn "[shipeasy] see_violation() called before a client was created — error dropped"
68
69
  return See::NullChain.new
69
70
  end
70
71
  client.see_violation(name)
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: 2.3.1
4
+ version: 3.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-29 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -140,8 +140,10 @@ files:
140
140
  - lib/shipeasy/i18n/label_fetcher.rb
141
141
  - lib/shipeasy/i18n/railtie.rb
142
142
  - lib/shipeasy/i18n/view_helpers.rb
143
+ - lib/shipeasy/logging.rb
143
144
  - lib/shipeasy/sdk/anon_id.rb
144
145
  - lib/shipeasy/sdk/eval.rb
146
+ - lib/shipeasy/sdk/internal_report.rb
145
147
  - lib/shipeasy/sdk/murmur3.rb
146
148
  - lib/shipeasy/sdk/openfeature.rb
147
149
  - lib/shipeasy/sdk/rack_middleware.rb