shipeasy-sdk 2.4.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 991e86824a2b5fb735a7d7f53dbff73c635f1a4adb5a3056e3b5c359a164dcbc
4
- data.tar.gz: 897872d04e78e03dd76f26b5e82108a5a18ef7eea749cf9657ced843e63843c4
3
+ metadata.gz: 49743db7faf0600dcc8a7cacb3f7a6fc7418c4d3d125efa6fdd1067b24fe4d16
4
+ data.tar.gz: 031b3acc69308ce512fb67c073b1f7f1894e276e17ab23f5ff3495678c2dab99
5
5
  SHA512:
6
- metadata.gz: 540e78bcb95b819ad2a74c8d69387f5ed252b9b6fc9c12ca89f0aa9b1d6ff1653197394721f35931808abb89fa0a4bc002ce7f1c152857e5e80bfd6759bf2c8e
7
- data.tar.gz: 36a1275319ef6958380451dd186e0dcd90cbdb04110b0cdf96e59a5e013d8a7a730456672325f9445eae0c4e2ac5f9ace72c936f9d039d9575e0fbd4c5933889
6
+ metadata.gz: 479ca3e747f954922315f6bb062b385ba8e7710ac37799d7a732f8da63de87321d5c497b8ee3cc605cb80c66bd9f4ebbceea6acc9486ff7d37d8f0b9c153f628
7
+ data.tar.gz: 1f9a70f2ccff8c6fcaeb5a9e913657fb88ceac7c37505f21d409880740d1d7af4b952732a3607ee94908a1e8c1921d23e9997852c3622e8a315ab1168a2c2def
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.
@@ -25,13 +25,30 @@ module Shipeasy
25
25
  # Advanced `configure` options — threaded into the global Engine `configure`
26
26
  # builds, so callers never construct an Engine themselves:
27
27
  # - env (default "prod"): deployment tag on see() events + usage telemetry.
28
- # - disable_telemetry (default false): opt out of per-eval usage telemetry.
28
+ # - is_network_enabled (default: environment-derived): master switch for ALL
29
+ # outbound requests (flag/experiment/config fetch, track, exposure logging,
30
+ # see() reports, AND telemetry). Defaults ON in production and OFF in every
31
+ # other environment, so a dev machine / CI run stays fully offline unless
32
+ # you opt in. Explicit true/false always overrides. "Production" is decided
33
+ # from SHIPEASY_ENV / RAILS_ENV / RACK_ENV / APP_ENV, then the `env` tag
34
+ # (see sdk/env.rb). When off, reads answer from overrides / in-code
35
+ # defaults and nothing is sent.
36
+ # - disable_telemetry (default: environment-derived — off in prod, i.e.
37
+ # telemetry ON in prod / OFF outside prod): opt out of per-eval usage
38
+ # telemetry. Explicit true/false overrides; forced off when the network is
39
+ # disabled.
29
40
  # - telemetry_url: override the telemetry endpoint (rarely needed).
30
41
  # - private_attributes: attribute keys stripped from every outbound event
31
42
  # before it leaves the process (they still drive targeting locally).
32
43
  # - sticky_store: pin a user's experiment group across re-buckets.
33
- attr_accessor :env, :disable_telemetry, :telemetry_url,
34
- :private_attributes, :sticky_store
44
+ # - disable_internal_error_reporting (default false): opt out of the
45
+ # SDK's self-monitoring channel. When the SDK's last-resort guard
46
+ # swallows one of its OWN internal errors it normally ships a structured
47
+ # see event to Shipeasy's own project (NOT yours) so the SDK team can
48
+ # track SDK bugs; set true to disable that entirely.
49
+ attr_accessor :env, :is_network_enabled, :disable_telemetry, :telemetry_url,
50
+ :private_attributes, :sticky_store,
51
+ :disable_internal_error_reporting
35
52
 
36
53
  # SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
37
54
  # One of :silent, :error, :warn (default), :info, :debug (strings accepted
@@ -69,12 +86,16 @@ module Shipeasy
69
86
  :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
70
87
 
71
88
  def initialize
72
- @base_url = "https://edge.shipeasy.dev"
89
+ @base_url = "https://api.shipeasy.ai"
73
90
  @attributes = nil
74
91
  @init = true
75
92
  @poll = false
76
93
  @env = "prod"
77
- @disable_telemetry = false
94
+ # nil ⇒ environment-derived default (see Engine / sdk/env.rb). An explicit
95
+ # true/false set in the configure block always overrides.
96
+ @is_network_enabled = nil
97
+ @disable_telemetry = nil
98
+ @disable_internal_error_reporting = false
78
99
  @telemetry_url = nil
79
100
  @private_attributes = nil
80
101
  @sticky_store = nil
@@ -152,23 +173,25 @@ module Shipeasy
152
173
  api_key: cfg.api_key,
153
174
  base_url: cfg.base_url,
154
175
  env: cfg.env,
176
+ is_network_enabled: cfg.is_network_enabled,
155
177
  disable_telemetry: cfg.disable_telemetry,
156
178
  telemetry_url: cfg.telemetry_url,
157
179
  private_attributes: cfg.private_attributes,
158
180
  sticky_store: cfg.sticky_store,
159
181
  log_level: cfg.log_level,
182
+ disable_internal_error_reporting: cfg.disable_internal_error_reporting,
160
183
  )
161
184
  @engine = engine
162
185
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
163
186
  # reset/reconfigure could nil out before the thread runs).
164
187
  if cfg.poll
165
- Thread.new do
188
+ @boot_thread = Thread.new do
166
189
  engine.init # initial fetch + background poll thread
167
190
  rescue => e
168
191
  Shipeasy::Logging.error "[shipeasy] configure(poll) background poll failed: #{e.message}"
169
192
  end
170
193
  elsif cfg.init
171
- Thread.new do
194
+ @boot_thread = Thread.new do
172
195
  engine.init_once
173
196
  rescue => e
174
197
  Shipeasy::Logging.error "[shipeasy] configure() one-shot fetch failed: #{e.message}"
@@ -319,6 +342,13 @@ module Shipeasy
319
342
 
320
343
  # Reset the config back to defaults — primarily for tests.
321
344
  def reset_config!
345
+ # Reap the fire-and-forget boot thread first: it holds its own engine
346
+ # reference, so left alive it can call init/init_once during the NEXT
347
+ # test example and trip any_instance expectations armed there.
348
+ if (t = @boot_thread)
349
+ t.join(1) || t.kill
350
+ @boot_thread = nil
351
+ end
322
352
  @config = nil
323
353
  @flags_pid = nil
324
354
  @flags&.destroy
@@ -4,11 +4,13 @@ require "json"
4
4
  require "thread"
5
5
  require "cgi"
6
6
  require_relative "logging"
7
+ require_relative "sdk/env"
7
8
  require_relative "sdk/eval"
8
9
  require_relative "sdk/telemetry"
9
10
  require_relative "sdk/anon_id"
10
11
  require_relative "sdk/sticky_store"
11
12
  require_relative "sdk/see"
13
+ require_relative "sdk/internal_report"
12
14
 
13
15
  module Shipeasy
14
16
  # The heavyweight engine: owns the api key, HTTP transport, the blob cache,
@@ -29,12 +31,12 @@ module Shipeasy
29
31
  AnonId = Shipeasy::SDK::AnonId
30
32
  See = Shipeasy::SDK::See
31
33
 
32
- DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
34
+ DEFAULT_BASE_URL = "https://api.shipeasy.ai"
33
35
  # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
34
36
  # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
35
37
  DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
36
38
 
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)
39
+ def initialize(api_key:, base_url: nil, env: "prod", is_network_enabled: nil, disable_telemetry: nil, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil, log_level: nil, disable_internal_error_reporting: false)
38
40
  # SDK-wide diagnostic verbosity. Set the leveled logger from the passed
39
41
  # level (default :warn; unknown falls back to :warn). The logger is
40
42
  # module-scoped, so the last-built engine wins — mirrors the TS SDK,
@@ -51,21 +53,37 @@ module Shipeasy
51
53
  # track(), where the listed keys are dropped from the props bag.
52
54
  @private_attributes = (private_attributes || []).map(&:to_s)
53
55
  # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
54
- # Threaded into get_experiment so an enrolled unit locks to its first
56
+ # Threaded into experiment eval so an enrolled unit locks to its first
55
57
  # assigned variant. Built-in: InMemoryStickyStore.
56
58
  @sticky_store = sticky_store
57
59
  # Test mode: no network, ever. init/init_once/track become no-ops and
58
60
  # evaluation answers come purely from local overrides. Built via the
59
61
  # Engine.for_testing factory; see clear_overrides / override_*.
60
62
  @test_mode = test_mode
61
- # Per-evaluation usage telemetry. ON by default; pass
62
- # disable_telemetry: true to opt out. See telemetry.rb.
63
+ # Environment-derived egress default. Both the master network switch and
64
+ # usage telemetry default ON in production and OFF everywhere else, so an
65
+ # app that embeds the SDK is quiet by default on a dev machine or in CI.
66
+ # The production decision consults native env vars first (SHIPEASY_ENV /
67
+ # RAILS_ENV / RACK_ENV / APP_ENV), then falls back to the configured `env`
68
+ # tag. See sdk/env.rb.
69
+ prod = Shipeasy::SDK::Env.is_production_env(env)
70
+ # Master network gate. test_mode always forces the SDK fully offline;
71
+ # otherwise honour an explicit is_network_enabled, else default to
72
+ # prod-on. When @offline, every fetch / track / exposure / see() / poll /
73
+ # telemetry send is a no-op — reads answer from overrides / in-code
74
+ # defaults only.
75
+ network_enabled = @test_mode ? false : (is_network_enabled.nil? ? prod : (is_network_enabled ? true : false))
76
+ @offline = !network_enabled
77
+ # Per-evaluation usage telemetry. Honour an explicit disable_telemetry,
78
+ # else default to prod-on (off outside production). Forced off whenever
79
+ # the master network switch is off. See telemetry.rb.
80
+ telemetry_disabled = @offline || (disable_telemetry.nil? ? !prod : (disable_telemetry ? true : false))
63
81
  @telemetry = Telemetry.new(
64
82
  endpoint: telemetry_url || Telemetry::DEFAULT_TELEMETRY_URL,
65
83
  sdk_key: api_key,
66
84
  side: "server",
67
85
  env: env,
68
- disabled: disable_telemetry,
86
+ disabled: telemetry_disabled,
69
87
  )
70
88
  @flags_blob = nil
71
89
  @exps_blob = nil
@@ -88,6 +106,22 @@ module Shipeasy
88
106
  # see() structured error reporting. Per-process spam guard, bound here so
89
107
  # repeated reports of the same issue collapse to one send. See see.rb.
90
108
  @see_limiter = See::Limiter.new
109
+ # Auto-exposure dedup set: assign() logs a single exposure per
110
+ # (unit, experiment, group) so repeated assigns in one process don't spam
111
+ # /collect. Bounded — cleared when it grows past ~5000 keys.
112
+ @exposure_seen = {}
113
+ # Self-monitoring channel: when safe_run swallows one of the SDK's OWN
114
+ # internal errors, it also ships a see event to Shipeasy's own project
115
+ # (a baked-in destination, distinct from the consumer's see() path) so
116
+ # the SDK team can track SDK bugs across every app. Fire-and-forget,
117
+ # never raises. On by default; forced off in test mode (no network) and
118
+ # opt-out-able via disable_internal_error_reporting. Module-scoped, so
119
+ # the last-built engine wins — mirrors set_level / the TS reference.
120
+ Shipeasy::SDK::InternalReport.set_context(
121
+ side: "server",
122
+ sdk_version: Shipeasy::SDK::VERSION,
123
+ enabled: !@offline && !disable_internal_error_reporting,
124
+ )
91
125
  # Register as the default client backing the module-level Shipeasy::SDK
92
126
  # .see/.see_violation funcs (last constructed wins — the server-SDK
93
127
  # analog of TS's shipeasy({key}) configure call).
@@ -136,14 +170,14 @@ module Shipeasy
136
170
  end
137
171
 
138
172
  def init
139
- return if @test_mode
173
+ return if @offline
140
174
  fetch_all
141
175
  @initialized = true
142
176
  start_poll
143
177
  end
144
178
 
145
179
  def init_once
146
- return if @test_mode
180
+ return if @offline
147
181
  return if @initialized
148
182
  fetch_all
149
183
  @initialized = true
@@ -283,48 +317,68 @@ module Shipeasy
283
317
  end
284
318
  end
285
319
 
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,
320
+ # Assign +user+ within +universe_name+. A universe is a mutual-exclusion
321
+ # pool, so a unit lands in AT MOST ONE experiment; the returned
322
+ # Eval::Assignment exposes the variant + resolved params and auto-logs a
323
+ # single exposure when enrolled. An un-enrolled unit still resolves get()
324
+ # to the universe defaults. Never raises. This is the sole experiment read
325
+ # path — there is no get_experiment (a caller asks a universe, not an
326
+ # experiment). Internal: the public surface is universe(name).assign(user).
327
+ def assign_universe(universe_name, user)
328
+ empty = Eval::Assignment.new(nil, nil, {})
329
+ safe_run("assign_universe('#{universe_name}')", empty) do
330
+ @telemetry.emit("experiment", universe_name)
331
+ u = with_anon_id(user)
332
+ flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
333
+
334
+ universe = exps_blob&.dig("universes", universe_name.to_s)
335
+ param_defaults = Eval.param_defaults_from_schema(
336
+ universe && (universe["param_schema"] || universe[:param_schema])
302
337
  )
338
+ not_enrolled = Eval::Assignment.new(nil, nil, param_defaults || {})
339
+ next not_enrolled unless exps_blob
340
+
341
+ # Candidate running experiments in this universe. Deterministic order:
342
+ # pool-slice offset asc (slices are disjoint so <=1 matches under
343
+ # pooling), then name. A universe-held-out or unallocated unit falls
344
+ # through to the defaults-only handle.
345
+ candidates = (exps_blob["experiments"] || {}).select do |_name, exp|
346
+ exp["universe"] == universe_name.to_s && exp["status"] == "running"
347
+ end.sort_by { |name, exp| [(exp["poolOffsetBp"] || 0), name] }
348
+
349
+ landed = nil
350
+ candidates.each do |name, exp|
351
+ result = eval_experiment(name, exp, u, flags_blob, exps_blob)
352
+ next unless result.in_experiment
353
+ post_exposure(u, name, result.group)
354
+ landed = Eval::Assignment.new(name, result.group, result.params || {})
355
+ break
356
+ # not enrolled: try the next candidate — under pooling only one slice
357
+ # can match, so the loop lands on the winner (or falls through).
358
+ end
359
+
360
+ landed || not_enrolled
303
361
  end
362
+ end
304
363
 
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
364
+ # A reusable handle bound to one universe. +assign(user)+ picks the <=1
365
+ # experiment the unit is pooled into and auto-logs a single exposure. See
366
+ # assign_universe.
367
+ def universe(name)
368
+ UniverseHandle.new(self, name)
369
+ end
313
370
 
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
371
+ # Returned by Engine#universe. Binds a universe name so callers can reuse
372
+ # the handle: `engine.universe("checkout").assign(user)`.
373
+ class UniverseHandle
374
+ def initialize(engine, name)
375
+ @engine = engine
376
+ @name = name
325
377
  end
326
378
 
327
- result
379
+ def assign(user)
380
+ @engine.assign_universe(@name, user)
381
+ end
328
382
  end
329
383
 
330
384
  # Public hook for the bound Shipeasy::Client: normalise an attribute hash
@@ -363,9 +417,8 @@ module Shipeasy
363
417
  # for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
364
418
  def evaluate(user)
365
419
  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]
420
+ flags_blob, exps_blob, flag_ov, config_ov = @mutex.synchronize do
421
+ [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup]
369
422
  end
370
423
 
371
424
  flags = {}
@@ -378,18 +431,30 @@ module Shipeasy
378
431
  configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
379
432
  end
380
433
 
434
+ # Per-experiment result carries the universe name; a top-level universes
435
+ # map exposes each universe's param defaults so the client can resolve
436
+ # universe(name).get() to a default even when the unit is not enrolled.
381
437
  experiments = {}
438
+ universes = {}
382
439
  (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
440
+ uni_name = exp["universe"]
441
+ unless universes.key?(uni_name)
442
+ uni = exps_blob&.dig("universes", uni_name)
443
+ universes[uni_name] = {
444
+ "defaults" => Eval.param_defaults_from_schema(uni && (uni["param_schema"] || uni[:param_schema])) || {},
445
+ }
387
446
  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 }
447
+ r = eval_experiment(name, exp, u, flags_blob, exps_blob, emit_telemetry: false)
448
+ experiments[name] = {
449
+ "inExperiment" => r.in_experiment,
450
+ "group" => r.in_experiment ? r.group : "control",
451
+ "params" => r.in_experiment ? (r.params || {}) : {},
452
+ "universe" => uni_name,
453
+ }
390
454
  end
391
455
 
392
- { "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
456
+ { "flags" => flags, "configs" => configs, "experiments" => experiments,
457
+ "killswitches" => {}, "universes" => universes }
393
458
  end
394
459
 
395
460
  # Return the cross-platform SSR bootstrap <script> tag for a request:
@@ -422,7 +487,7 @@ module Shipeasy
422
487
 
423
488
  def track(user_id, event_name, props = {})
424
489
  safe_run("track('#{event_name}')", nil) do
425
- next if @test_mode
490
+ next if @offline
426
491
 
427
492
  safe_props = strip_private(props)
428
493
 
@@ -445,40 +510,6 @@ module Shipeasy
445
510
  end
446
511
  end
447
512
 
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
513
  # ---- see() structured error reporting -------------------------------
483
514
 
484
515
  # Report a caught exception (or thrown non-exception). Fire-and-forget;
@@ -507,8 +538,8 @@ module Shipeasy
507
538
  private
508
539
 
509
540
  # 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
541
+ # get_config / assign_universe / get_killswitch / track) unable to raise
542
+ # into product code, even if an internal invariant is
512
543
  # violated. Runs the block; on any StandardError it logs at :error and
513
544
  # returns +fallback+ (the method's documented safe default). +label+ names
514
545
  # the method for the log line.
@@ -516,13 +547,91 @@ module Shipeasy
516
547
  yield
517
548
  rescue StandardError => e
518
549
  Shipeasy::Logging.error "[shipeasy] #{label} failed — returning safe default: #{e.message}"
550
+ # A caught error here is by definition "on our end" — an internal SDK
551
+ # failure, not the caller's — so in addition to logging locally it is
552
+ # reported to Shipeasy's own project via the self-monitoring channel
553
+ # (fire-and-forget, never raises). The label's stable stem (e.g.
554
+ # "get_flag" from "get_flag('new_checkout')") is the issue subject, so
555
+ # occurrences of the same bug dedupe regardless of the resource name.
556
+ Shipeasy::SDK::InternalReport.report(internal_subject(label), e)
519
557
  fallback
520
558
  end
521
559
 
560
+ # The stable subject for an internal-error report: strip the variable
561
+ # "('resource')" argument off a safe_run label so the fingerprint carries
562
+ # no variable data and identical bugs dedupe into one issue.
563
+ def internal_subject(label)
564
+ label.to_s.sub(/\(.*\)\z/, "")
565
+ end
566
+
567
+ # Evaluate one experiment by name for +user+ — override -> full classify
568
+ # pipeline (targeting -> universe holdout -> holdout gate -> sticky ->
569
+ # allocation -> group), merging the universe defaults under the assigned
570
+ # variant. Returns an Eval::ExperimentResult. Reused by assign_universe and
571
+ # the SSR evaluate() bootstrap (keyed by experiment name). Emits the
572
+ # per-experiment telemetry beacon exactly once (never on the override
573
+ # short-circuit), unless +emit_telemetry+ is false (the batch evaluate()
574
+ # path is not a per-experiment exposure). +user+ is expected pre-normalised
575
+ # (with_anon_id).
576
+ def eval_experiment(name, exp, user, flags_blob, exps_blob, emit_telemetry: true)
577
+ key = name.to_s
578
+ override = @mutex.synchronize { @exp_overrides[key] }
579
+ if override
580
+ universe = exps_blob&.dig("universes", exp && exp["universe"])
581
+ param_defaults = Eval.param_defaults_from_schema(
582
+ universe && (universe["param_schema"] || universe[:param_schema])
583
+ )
584
+ return Eval::ExperimentResult.new(
585
+ in_experiment: true,
586
+ group: override[:group],
587
+ params: Eval.merge_params(param_defaults, override[:params]),
588
+ )
589
+ end
590
+
591
+ @telemetry.emit("experiment", name) if emit_telemetry
592
+ Eval.eval_experiment(
593
+ exp, flags_blob, exps_blob, user,
594
+ exp_name: key, sticky_store: @sticky_store,
595
+ )
596
+ end
597
+
598
+ # POST a single exposure for an enrolled (user, experiment, group). Deduped
599
+ # per process (bounded set) so repeated assign() calls in one server don't
600
+ # spam /collect. Fire-and-forget; no-op in test mode. This is how
601
+ # assign_universe auto-logs — the browser's auto-exposure parity for SSR.
602
+ def post_exposure(user, experiment, group)
603
+ return if @offline
604
+ u = user.transform_keys(&:to_s)
605
+ uid = u["user_id"] || u["anonymous_id"]
606
+ dedup_key = "#{uid}:#{experiment}:#{group}"
607
+ @mutex.synchronize do
608
+ return if @exposure_seen.key?(dedup_key)
609
+ @exposure_seen.clear if @exposure_seen.size > 5000
610
+ @exposure_seen[dedup_key] = true
611
+ end
612
+
613
+ payload = JSON.generate({
614
+ events: [{
615
+ type: "exposure",
616
+ experiment: experiment.to_s,
617
+ group: group,
618
+ user_id: uid.to_s,
619
+ ts: (Time.now.to_f * 1000).to_i,
620
+ }],
621
+ })
622
+
623
+ Thread.new do
624
+ post("/collect", payload)
625
+ rescue => e
626
+ Shipeasy::Logging.warn "[shipeasy] exposure send failed: #{e.message}"
627
+ end
628
+ nil
629
+ end
630
+
522
631
  # Build the wire event and fire-and-forget POST it to /collect. No-op in
523
632
  # test mode (mirrors track). Spam-guarded. Never raises into caller code.
524
633
  def dispatch_see(built)
525
- return if @test_mode
634
+ return if @offline
526
635
 
527
636
  ev = See.build_event(
528
637
  built.problem,
@@ -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
  #
@@ -0,0 +1,55 @@
1
+ module Shipeasy
2
+ module SDK
3
+ # Native runtime-environment detection.
4
+ #
5
+ # Used ONLY to pick the DEFAULT for outbound egress when the caller does not
6
+ # set it explicitly:
7
+ # - is the SDK allowed to make network requests at all (is_network_enabled)?
8
+ # - is per-evaluation usage telemetry allowed (disable_telemetry)?
9
+ #
10
+ # Both default to ON in production and OFF everywhere else, so a local/dev/CI
11
+ # run of an app that embeds the SDK never phones home unless it explicitly
12
+ # opts in.
13
+ #
14
+ # Precedence for the production decision (mirrors the TS SDK's src/env.ts):
15
+ # 1. A native runtime env var — SHIPEASY_ENV, then RAILS_ENV, then RACK_ENV,
16
+ # then APP_ENV. A value of "production"/"prod" (case-insensitive) ⇒ prod;
17
+ # anything else present ("development"/"staging"/"test"/…) ⇒ not prod.
18
+ # 2. When no native env var is set (e.g. serverless / some containers), fall
19
+ # back to the SDK's OWN configured `env` option, which the caller sets and
20
+ # which itself defaults to "prod". This keeps a real production deploy
21
+ # "on" by default while an `env: "dev"` config stays quiet.
22
+ #
23
+ # The env option is always present (it defaults to "prod"), so the production
24
+ # decision is always inferrable — the SDK never has to make the field required.
25
+ module Env
26
+ # Native env vars consulted, in precedence order.
27
+ NATIVE_ENV_VARS = %w[SHIPEASY_ENV RAILS_ENV RACK_ENV APP_ENV].freeze
28
+
29
+ module_function
30
+
31
+ # True when the host runtime looks like a production deployment.
32
+ # +configured_env+ is the SDK's own `env` option (dev/staging/prod); it is
33
+ # consulted ONLY when no native runtime env var is set.
34
+ def is_production_env(configured_env = nil)
35
+ native = read_native_env
36
+ return native == "production" || native == "prod" unless native.nil?
37
+
38
+ (configured_env || "prod").to_s.strip.downcase == "prod"
39
+ end
40
+
41
+ # Read the first present native env var (lowercased, trimmed), or nil when
42
+ # none of them is set to a non-empty value.
43
+ def read_native_env
44
+ NATIVE_ENV_VARS.each do |name|
45
+ raw = ENV[name]
46
+ next if raw.nil?
47
+
48
+ v = raw.strip.downcase
49
+ return v unless v.empty?
50
+ end
51
+ nil
52
+ end
53
+ end
54
+ end
55
+ 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.4.0"
3
+ VERSION = "3.1.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.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
@@ -142,7 +142,9 @@ files:
142
142
  - lib/shipeasy/i18n/view_helpers.rb
143
143
  - lib/shipeasy/logging.rb
144
144
  - lib/shipeasy/sdk/anon_id.rb
145
+ - lib/shipeasy/sdk/env.rb
145
146
  - lib/shipeasy/sdk/eval.rb
147
+ - lib/shipeasy/sdk/internal_report.rb
146
148
  - lib/shipeasy/sdk/murmur3.rb
147
149
  - lib/shipeasy/sdk/openfeature.rb
148
150
  - lib/shipeasy/sdk/rack_middleware.rb