shipeasy-sdk 2.4.0 → 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: 991e86824a2b5fb735a7d7f53dbff73c635f1a4adb5a3056e3b5c359a164dcbc
4
- data.tar.gz: 897872d04e78e03dd76f26b5e82108a5a18ef7eea749cf9657ced843e63843c4
3
+ metadata.gz: 3a34364fd54857fe0b04997063d9d2bbb6296e281a3db8b7389efd84c5f1b4a1
4
+ data.tar.gz: bb1c68285633d2f1a5709286197b9a0aacaf7c78d950908e414f3fde64f44144
5
5
  SHA512:
6
- metadata.gz: 540e78bcb95b819ad2a74c8d69387f5ed252b9b6fc9c12ca89f0aa9b1d6ff1653197394721f35931808abb89fa0a4bc002ce7f1c152857e5e80bfd6759bf2c8e
7
- data.tar.gz: 36a1275319ef6958380451dd186e0dcd90cbdb04110b0cdf96e59a5e013d8a7a730456672325f9445eae0c4e2ac5f9ace72c936f9d039d9575e0fbd4c5933889
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,
@@ -62,11 +62,31 @@ module Shipeasy
62
62
  default
63
63
  end
64
64
 
65
- def get_experiment(name, default_params, decode = nil)
66
- @engine.get_experiment(name, @attributes, default_params, decode)
67
- rescue StandardError => e
68
- Shipeasy::Logging.error "[shipeasy] Client#get_experiment('#{name}') failed returning safe default: #{e.message}"
69
- Shipeasy::SDK::Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
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)
73
+ end
74
+
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
70
90
  end
71
91
 
72
92
  # Killswitches are not user-scoped; forwarded straight to the engine.
@@ -84,13 +104,6 @@ module Shipeasy
84
104
  Shipeasy::Logging.error "[shipeasy] Client#track('#{event_name}') failed: #{e.message}"
85
105
  nil
86
106
  end
87
-
88
- def log_exposure(experiment_name)
89
- @engine.log_exposure(@attributes, experiment_name)
90
- rescue StandardError => e
91
- Shipeasy::Logging.error "[shipeasy] Client#log_exposure('#{experiment_name}') failed: #{e.message}"
92
- nil
93
- end
94
107
  end
95
108
 
96
109
  # Raised by Shipeasy::Client when constructed before Shipeasy.configure.
@@ -30,8 +30,14 @@ 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
35
41
 
36
42
  # SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
37
43
  # One of :silent, :error, :warn (default), :info, :debug (strings accepted
@@ -69,12 +75,13 @@ module Shipeasy
69
75
  :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
70
76
 
71
77
  def initialize
72
- @base_url = "https://edge.shipeasy.dev"
78
+ @base_url = "https://api.shipeasy.ai"
73
79
  @attributes = nil
74
80
  @init = true
75
81
  @poll = false
76
82
  @env = "prod"
77
83
  @disable_telemetry = false
84
+ @disable_internal_error_reporting = false
78
85
  @telemetry_url = nil
79
86
  @private_attributes = nil
80
87
  @sticky_store = nil
@@ -157,6 +164,7 @@ module Shipeasy
157
164
  private_attributes: cfg.private_attributes,
158
165
  sticky_store: cfg.sticky_store,
159
166
  log_level: cfg.log_level,
167
+ disable_internal_error_reporting: cfg.disable_internal_error_reporting,
160
168
  )
161
169
  @engine = engine
162
170
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
@@ -9,6 +9,7 @@ require_relative "sdk/telemetry"
9
9
  require_relative "sdk/anon_id"
10
10
  require_relative "sdk/sticky_store"
11
11
  require_relative "sdk/see"
12
+ require_relative "sdk/internal_report"
12
13
 
13
14
  module Shipeasy
14
15
  # The heavyweight engine: owns the api key, HTTP transport, the blob cache,
@@ -29,12 +30,12 @@ module Shipeasy
29
30
  AnonId = Shipeasy::SDK::AnonId
30
31
  See = Shipeasy::SDK::See
31
32
 
32
- DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
33
+ DEFAULT_BASE_URL = "https://api.shipeasy.ai"
33
34
  # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
34
35
  # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
35
36
  DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
36
37
 
37
- 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)
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)
38
39
  # SDK-wide diagnostic verbosity. Set the leveled logger from the passed
39
40
  # level (default :warn; unknown falls back to :warn). The logger is
40
41
  # module-scoped, so the last-built engine wins — mirrors the TS SDK,
@@ -51,7 +52,7 @@ module Shipeasy
51
52
  # track(), where the listed keys are dropped from the props bag.
52
53
  @private_attributes = (private_attributes || []).map(&:to_s)
53
54
  # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
54
- # 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
55
56
  # assigned variant. Built-in: InMemoryStickyStore.
56
57
  @sticky_store = sticky_store
57
58
  # Test mode: no network, ever. init/init_once/track become no-ops and
@@ -88,6 +89,22 @@ module Shipeasy
88
89
  # see() structured error reporting. Per-process spam guard, bound here so
89
90
  # repeated reports of the same issue collapse to one send. See see.rb.
90
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
+ )
91
108
  # Register as the default client backing the module-level Shipeasy::SDK
92
109
  # .see/.see_violation funcs (last constructed wins — the server-SDK
93
110
  # analog of TS's shipeasy({key}) configure call).
@@ -283,48 +300,68 @@ module Shipeasy
283
300
  end
284
301
  end
285
302
 
286
- def get_experiment(name, user, default_params, decode = nil)
287
- safe_run("get_experiment('#{name}')", Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)) do
288
- get_experiment_inner(name, user, default_params, decode)
289
- end
290
- end
291
-
292
- def get_experiment_inner(name, user, default_params, decode = nil)
293
- key = name.to_s
294
- override = @mutex.synchronize { @exp_overrides[key] }
295
- if override
296
- params = override[:params]
297
- params = decode.call(params) if decode
298
- return Eval::ExperimentResult.new(
299
- in_experiment: true,
300
- group: override[:group],
301
- 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])
302
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
303
344
  end
345
+ end
304
346
 
305
- @telemetry.emit("experiment", name)
306
- flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
307
- exp = exps_blob&.dig("experiments", name)
308
- result = Eval.eval_experiment(
309
- exp, flags_blob, exps_blob, with_anon_id(user),
310
- exp_name: name.to_s, sticky_store: @sticky_store,
311
- )
312
- 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
313
353
 
314
- if result.in_experiment && decode
315
- begin
316
- result = Eval::ExperimentResult.new(
317
- in_experiment: true,
318
- group: result.group,
319
- params: decode.call(result.params),
320
- )
321
- rescue => e
322
- Shipeasy::Logging.warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
323
- return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
324
- 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
325
360
  end
326
361
 
327
- result
362
+ def assign(user)
363
+ @engine.assign_universe(@name, user)
364
+ end
328
365
  end
329
366
 
330
367
  # Public hook for the bound Shipeasy::Client: normalise an attribute hash
@@ -363,9 +400,8 @@ module Shipeasy
363
400
  # for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
364
401
  def evaluate(user)
365
402
  u = with_anon_id(user)
366
- flags_blob, exps_blob, flag_ov, config_ov, exp_ov, sticky = @mutex.synchronize do
367
- [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup,
368
- @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]
369
405
  end
370
406
 
371
407
  flags = {}
@@ -378,18 +414,30 @@ module Shipeasy
378
414
  configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
379
415
  end
380
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.
381
420
  experiments = {}
421
+ universes = {}
382
422
  (exps_blob&.dig("experiments") || {}).each do |name, exp|
383
- if exp_ov.key?(name)
384
- ov = exp_ov[name]
385
- experiments[name] = { "inExperiment" => true, "group" => ov[:group], "params" => ov[:params] }
386
- 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
+ }
387
429
  end
388
- r = Eval.eval_experiment(exp, flags_blob, exps_blob, u, exp_name: name, sticky_store: sticky)
389
- 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
+ }
390
437
  end
391
438
 
392
- { "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
439
+ { "flags" => flags, "configs" => configs, "experiments" => experiments,
440
+ "killswitches" => {}, "universes" => universes }
393
441
  end
394
442
 
395
443
  # Return the cross-platform SSR bootstrap <script> tag for a request:
@@ -445,40 +493,6 @@ module Shipeasy
445
493
  end
446
494
  end
447
495
 
448
- # Emit an exposure event for an experiment at the server-side decision
449
- # point (parity with the browser's auto-exposure). The server is stateless
450
- # and never auto-logs, so call this when you actually present the
451
- # treatment. Re-evaluates the experiment for the user (a bare user_id
452
- # string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
453
- # exposure to /collect. No-op in test mode or when the user isn't enrolled.
454
- def log_exposure(user_or_user_id, experiment_name)
455
- safe_run("log_exposure('#{experiment_name}')", nil) do
456
- next if @test_mode
457
-
458
- user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
459
- result = get_experiment(experiment_name, user, {})
460
- next unless result.in_experiment
461
-
462
- u = user.transform_keys(&:to_s)
463
- payload = JSON.generate({
464
- events: [{
465
- type: "exposure",
466
- experiment: experiment_name.to_s,
467
- group: result.group,
468
- user_id: (u["user_id"] || u["anonymous_id"]).to_s,
469
- ts: (Time.now.to_f * 1000).to_i,
470
- }],
471
- })
472
-
473
- Thread.new do
474
- post("/collect", payload)
475
- rescue => e
476
- Shipeasy::Logging.warn "[shipeasy] log_exposure failed: #{e.message}"
477
- end
478
- nil
479
- end
480
- end
481
-
482
496
  # ---- see() structured error reporting -------------------------------
483
497
 
484
498
  # Report a caught exception (or thrown non-exception). Fire-and-forget;
@@ -507,8 +521,8 @@ module Shipeasy
507
521
  private
508
522
 
509
523
  # Last-resort guard that makes a public RUNTIME method (get_flag /
510
- # get_config / get_experiment / get_killswitch / track / log_exposure)
511
- # unable to raise into product code, even if an internal invariant is
524
+ # get_config / assign_universe / get_killswitch / track) unable to raise
525
+ # into product code, even if an internal invariant is
512
526
  # violated. Runs the block; on any StandardError it logs at :error and
513
527
  # returns +fallback+ (the method's documented safe default). +label+ names
514
528
  # the method for the log line.
@@ -516,9 +530,87 @@ module Shipeasy
516
530
  yield
517
531
  rescue StandardError => e
518
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)
519
540
  fallback
520
541
  end
521
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
+
522
614
  # Build the wire event and fire-and-forget POST it to /collect. No-op in
523
615
  # test mode (mirrors track). Spam-guarded. Never raises into caller code.
524
616
  def dispatch_see(built)
@@ -3,7 +3,7 @@
3
3
  # Every diagnostic the SDK emits from a *caught* error goes through here, so a
4
4
  # single `log_level` config option (default :warn) controls the SDK's stderr
5
5
  # output. The contract for the SDK's public RUNTIME methods (get_flag,
6
- # get_config, get_experiment, get_killswitch, track, log_exposure, see, …) is
6
+ # get_config, universe(...).assign, get_killswitch, track, see, …) is
7
7
  # that they NEVER raise into product code — so logging itself is best-effort
8
8
  # too: a broken/throwing $stderr can never take down a flag read.
9
9
  #
@@ -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.4.0"
3
+ VERSION = "3.0.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
@@ -143,6 +143,7 @@ files:
143
143
  - lib/shipeasy/logging.rb
144
144
  - lib/shipeasy/sdk/anon_id.rb
145
145
  - lib/shipeasy/sdk/eval.rb
146
+ - lib/shipeasy/sdk/internal_report.rb
146
147
  - lib/shipeasy/sdk/murmur3.rb
147
148
  - lib/shipeasy/sdk/openfeature.rb
148
149
  - lib/shipeasy/sdk/rack_middleware.rb