shipeasy-sdk 3.1.1 → 3.4.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: e515d6b49fbcc5b0f080ff849666e2ddea3f1d5819b8fa3298755190d32cacb4
4
- data.tar.gz: 12b01b42ade6f5ee60ade42e4f6ee8af1bcb26999086499cefc3d6a4c39ee575
3
+ metadata.gz: c1b1f478bea3279f979e85e04476381f719d3db27aa29ffb23447768ceefc560
4
+ data.tar.gz: c0d4f8f1ca49a6a1b34ac3b1198a3788dc115f640eb5845fb388a66175e1384c
5
5
  SHA512:
6
- metadata.gz: 73ad05284d98820cbd2adcb19d7b9e8d4fff78b96f75e236f0c1f66722a492653eb356ab371e65c1e25919b29d63f1483c241aafa9d9a6bb2c513a15fc3a3e8b
7
- data.tar.gz: 483dfab6644615b3f2774afed8182f6be4267d8f5302b58bc2fe5bf1edfce1d4cdf26b4587f5ff37a7b6eb52c1d6d57bb0434d789e047cf72f36206522102c03
6
+ metadata.gz: 94790b0a88b1524f35faf706245339db83908565b5adec3a7f8b9f5775d476cb703ce00ac55a697d79a343a1abe0c94145c2fd693538c1aecbdab81385525148
7
+ data.tar.gz: f35b460dd560167674a1a278c1e76600fd59040016ad06b3129ee77c5dd95d68d57d0c7695ca06dee7f7870f15489655ee20ca644b6d1a24d416fdf280c2daf7
data/docs/skill/SKILL.md CHANGED
@@ -60,9 +60,9 @@ flags.get_killswitch("payments") # true = killed; optional switch_ke
60
60
 
61
61
  # Ask the UNIVERSE, not the experiment. Returns an Assignment (never raises):
62
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"))
63
+ # .get(field, fallback = nil, exposure: true) → variant override ?? universe default ?? fallback
64
+ assignment = flags.universe("checkout").assign # side-effect free; exposure fires on first get read
65
+ render_cta(assignment.get("label", "Buy now")) # first read logs one deduped exposure (exposure: false to peek)
66
66
 
67
67
  flags.track("purchase", { revenue: 49 }) # conversion / metric event
68
68
  ```
@@ -126,12 +126,17 @@ rescue => e
126
126
  end
127
127
  ```
128
128
 
129
+ Attach context with `.extras({...})` before `.to`, or inline as
130
+ `.to(outcome, {...})`. To attach it from anywhere without threading it into the
131
+ rescue, buffer it earlier with `Shipeasy.add_extras(order_id: id)` — it merges
132
+ into every see() report later in the same (fiber-local, per-request) scope.
133
+
129
134
  `Shipeasy.see_violation(name)` for non-exception problems;
130
135
  `Shipeasy.control_flow_exception(e).because(...)` marks expected control flow
131
136
  (reports nothing).
132
137
 
133
138
  → More: `pages/error-reporting.md` · snippets `snippets/ops/see.md`
134
- (`.extras`, violations, control-flow exceptions).
139
+ (`.extras`, `add_extras`, violations, control-flow exceptions).
135
140
 
136
141
  ## i18n (Rails)
137
142
 
@@ -85,6 +85,13 @@ module Shipeasy
85
85
  :cdn_base_url, :loader_url,
86
86
  :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
87
87
 
88
+ # When true, `i18n_t` renders the translation KEY verbatim instead of
89
+ # resolving its value, so tests/snapshots assert against stable data instead
90
+ # of copy that changes when a translation is edited. nil (default) ⇒
91
+ # env-derived: on when the native env is "test" (RAILS_ENV / RACK_ENV /
92
+ # SHIPEASY_ENV / APP_ENV), off otherwise. Set true/false to override.
93
+ attr_accessor :render_keys_only
94
+
88
95
  def initialize
89
96
  @base_url = "https://api.shipeasy.ai"
90
97
  @attributes = nil
@@ -108,6 +115,16 @@ module Shipeasy
108
115
  @manifest_cache_ttl = 60
109
116
  @label_file_cache_ttl = 3600
110
117
  @http_timeout = 1
118
+ # nil ⇒ env-derived (on under RAILS_ENV/…=="test"); true/false overrides.
119
+ @render_keys_only = nil
120
+ end
121
+
122
+ # Resolve the effective render_keys_only decision: an explicit true/false set
123
+ # in the configure block wins; otherwise default to env==test.
124
+ def render_keys_only?
125
+ return @render_keys_only unless @render_keys_only.nil?
126
+
127
+ Shipeasy::SDK::Env.is_test_env
111
128
  end
112
129
  end
113
130
 
@@ -309,6 +326,17 @@ module Shipeasy
309
326
  Shipeasy::SDK.control_flow_exception(err)
310
327
  end
311
328
 
329
+ # Ambient per-request see() extras — attach context from anywhere that
330
+ # merges into every see() report firing later in the same request, so you
331
+ # never thread it into the rescue block. See Shipeasy::SDK.add_extras.
332
+ def add_extras(extras = nil, **kwargs)
333
+ Shipeasy::SDK.add_extras(extras, **kwargs)
334
+ end
335
+
336
+ def clear_extras
337
+ Shipeasy::SDK.clear_extras
338
+ end
339
+
312
340
  # Replace the registered global engine + attributes transform (used by the
313
341
  # configure_for_* siblings — unlike configure, they replace so a test suite
314
342
  # can reconfigure between cases). Returns the engine.
@@ -350,8 +350,13 @@ module Shipeasy
350
350
  candidates.each do |name, exp|
351
351
  result = eval_experiment(name, exp, u, flags_blob, exps_blob)
352
352
  next unless result.in_experiment
353
- post_exposure(u, name, result.group)
354
- landed = Eval::Assignment.new(name, result.group, result.params || {})
353
+ group = result.group
354
+ # On-read exposure (spec step 7): defer the single exposure to the
355
+ # first param read via the callback, instead of firing it here at
356
+ # assign time.
357
+ landed = Eval::Assignment.new(name, group, result.params || {}, lambda {
358
+ post_exposure(u, name, group)
359
+ })
355
360
  break
356
361
  # not enrolled: try the next candidate — under pooling only one slice
357
362
  # can match, so the loop lands on the winner (or falls through).
@@ -34,6 +34,10 @@ module Shipeasy
34
34
 
35
35
  def i18n_t(key, variables = {}, profile: nil, chunk: nil)
36
36
  config = Shipeasy.config
37
+ # render_keys_only (default: env==test): return the key verbatim,
38
+ # skipping value resolution + interpolation so tests assert stable data.
39
+ return key if config.render_keys_only?
40
+
37
41
  label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
38
42
  profile: profile || config.profile,
39
43
  chunk: chunk || config.default_chunk,
@@ -38,6 +38,14 @@ module Shipeasy
38
38
  (configured_env || "prod").to_s.strip.downcase == "prod"
39
39
  end
40
40
 
41
+ # True when the host runtime looks like a test run — the first present
42
+ # native env var (SHIPEASY_ENV / RAILS_ENV / RACK_ENV / APP_ENV) is exactly
43
+ # "test" (what Rails/RSpec set). Used to default i18n render_keys_only on
44
+ # under test. No native var present ⇒ not test.
45
+ def is_test_env
46
+ read_native_env == "test"
47
+ end
48
+
41
49
  # Read the first present native env var (lowercased, trimmed), or nil when
42
50
  # none of them is set to a non-empty value.
43
51
  def read_native_env
@@ -125,6 +125,28 @@ module Shipeasy
125
125
  # universe assign() path share ONE classify. The added steps are all guarded
126
126
  # by presence checks, so a legacy blob (no hashVersion/pool/reserved) behaves
127
127
  # exactly as before.
128
+ # Resolve a forced override group for +uid+ (spec step 1): ID overrides
129
+ # (tier 1) beat cohort/GK overrides (tier 2); within cohort overrides the
130
+ # first (pre-sorted by priority) gate that passes wins. Returns the forced
131
+ # group name or nil. The caller applies eligibility + group-existence
132
+ # (forced-but-gated). Mirrors @shipeasy/core resolveForcedGroup.
133
+ def self.resolve_forced_group(exp, uid, flags_blob, user)
134
+ id_overrides = exp["idOverrides"] || exp[:idOverrides]
135
+ if id_overrides
136
+ by_id = id_overrides[uid]
137
+ return by_id if by_id && !by_id.to_s.empty?
138
+ end
139
+ cohort_overrides = exp["cohortOverrides"] || exp[:cohortOverrides]
140
+ if cohort_overrides
141
+ cohort_overrides.each do |co|
142
+ gname = co["gate"] || co[:gate]
143
+ gate = flags_blob&.dig("gates", gname)
144
+ return (co["group"] || co[:group]) if gate && eval_gate(gate, user)
145
+ end
146
+ end
147
+ nil
148
+ end
149
+
128
150
  def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
129
151
  universe_name = exp && exp["universe"]
130
152
  universe = exps_blob&.dig("universes", universe_name)
@@ -172,6 +194,21 @@ module Shipeasy
172
194
  groups = exp["groups"] || []
173
195
  salt8 = (salt || "")[0, 8]
174
196
 
197
+ # Durable overrides (spec step 1, forced-but-gated). Reached only after the
198
+ # unit passes targeting and is not held out, so an override may now pin the
199
+ # group — bypassing allocation + the weighted pick but NOT the gates above.
200
+ # ID overrides (tier 1) beat cohort/GK overrides (tier 2); a forced group
201
+ # that no longer exists falls through to normal allocation. No-op when
202
+ # unconfigured, so v1/v2 stay byte-identical. Mirrors @shipeasy/core.
203
+ forced = resolve_forced_group(exp, uid, flags_blob, user)
204
+ if forced
205
+ g = groups.find { |x| x["name"] == forced }
206
+ if g
207
+ sticky_store.set(uid, exp_name, { "g" => forced, "s" => salt8 }) if sticky_store && exp_name
208
+ return as_group.call(g)
209
+ end
210
+ end
211
+
175
212
  # Sticky short-circuit: an enrolled unit whose stored salt prefix still
176
213
  # matches skips allocation and returns the stored group. If the stored
177
214
  # group no longer exists, fall through to re-bucket + overwrite.
@@ -232,14 +269,19 @@ module Shipeasy
232
269
  attr_reader :group
233
270
 
234
271
  # +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 || {}
272
+ # enrolled; defaults-only (or {}) when not. +on_expose+ fires the single
273
+ # exposure the first time an enrolled param is read (nil when not
274
+ # enrolled — nothing to expose); deduped downstream.
275
+ def initialize(name, group, params, on_expose = nil)
276
+ @name = name
277
+ @group = group
278
+ @params = params || {}
279
+ @on_expose = on_expose
280
+ @exposed = false
240
281
  end
241
282
 
242
283
  # True iff the unit is enrolled in an experiment in this universe.
284
+ # Reading it does NOT log an exposure (only +get+ of a param does).
243
285
  def enrolled?
244
286
  !@group.nil?
245
287
  end
@@ -248,7 +290,15 @@ module Shipeasy
248
290
  # universe default, else +fallback+. Works even when not enrolled (the
249
291
  # variant layer is absent, so you get universeDefault ?? fallback).
250
292
  # Looks up both string and symbol keys.
251
- def get(field, fallback = nil)
293
+ #
294
+ # Exposure is logged **on read** (spec step 7): the first enrolled read
295
+ # fires the single exposure; pass +exposure: false+ to read without
296
+ # logging (peek).
297
+ def get(field, fallback = nil, exposure: true)
298
+ if exposure && !@exposed && @on_expose
299
+ @exposed = true
300
+ @on_expose.call
301
+ end
252
302
  if @params.key?(field)
253
303
  @params[field]
254
304
  elsif field.respond_to?(:to_s) && @params.key?(field.to_s)
@@ -1,4 +1,5 @@
1
1
  require_relative "anon_id"
2
+ require_relative "see"
2
3
 
3
4
  module Shipeasy
4
5
  module SDK
@@ -32,8 +33,10 @@ module Shipeasy
32
33
  begin
33
34
  status, headers, body = @app.call(env)
34
35
  ensure
35
- # Don't leak the id onto the next request handled by this thread.
36
+ # Don't leak the id or any ambient see() extras onto the next
37
+ # request handled by this thread.
36
38
  AnonId.current = nil
39
+ See::Context.clear
37
40
  end
38
41
  set_cookie!(headers, id, env) if minted
39
42
  [status, headers, body]
@@ -12,11 +12,22 @@
12
12
  #
13
13
  # Dispatch model (differs from TS, which uses a microtask): `.to(outcome)` is
14
14
  # the terminal — it builds the wire event and fire-and-forgets the POST to
15
- # /collect. `causes_the` and `extras` are chainable setters that may be called
16
- # in any order *before* `.to`:
15
+ # /collect. `causes_the` and `extras` are chainable setters called *before*
16
+ # `.to`; `.to` also accepts the extras inline as a second arg, so there is no
17
+ # ordering trap to remember:
17
18
  #
18
19
  # client.see(e).causes_the("checkout").to("use cached prices")
19
20
  # client.see(e).causes_the("checkout").extras({ order_id: oid }).to("use cached prices")
21
+ # client.see(e).causes_the("checkout").to("use cached prices", { order_id: oid })
22
+ #
23
+ # `.extras` chained AFTER `.to` is ignored with a warning (the report already
24
+ # went out) — it never raises into the rescue block.
25
+ #
26
+ # To attach context from anywhere in a request without threading it into the
27
+ # rescue block, use the ambient buffer (see `Context` below):
28
+ # `Shipeasy.add_extras(order_id: oid)` merges into EVERY see() report that fires
29
+ # later in the same request. It is fiber-local (concurrent requests never bleed)
30
+ # and the Rack middleware clears it at the end of each request.
20
31
  #
21
32
  # If you don't know the consequence of an exception, don't catch it.
22
33
 
@@ -211,27 +222,65 @@ module Shipeasy
211
222
  end
212
223
  alias causesThe causes_the
213
224
 
225
+ # Attach debugging metadata. Chainable — call repeatedly (keys merge,
226
+ # later wins) *before* `.to`. Called after `.to` it is a no-op with a
227
+ # warning: the report already shipped, so there is nothing to amend and,
228
+ # crucially, it must not raise into the caller's rescue block. Use
229
+ # `.to(outcome, extras)` or `Shipeasy.add_extras` for late/scattered
230
+ # context instead.
214
231
  def extras(extras)
215
- if extras.is_a?(Hash) && !extras.empty?
216
- @extras = (@extras || {}).merge(extras)
232
+ if @done
233
+ Shipeasy::Logging.warn(
234
+ "[shipeasy] see() .extras(...) called after .to(...) is ignored — " \
235
+ "pass extras to .to(outcome, extras) or call .extras before .to"
236
+ )
237
+ return self
217
238
  end
239
+ merge_extras(extras)
218
240
  self
219
241
  end
220
242
 
221
243
  # Terminal: build the event and fire-and-forget the report. Idempotent.
222
- def to(outcome)
223
- return if @done
244
+ # `extras` may be passed inline here as the trailing form
245
+ # `.to(outcome, { order_id: oid })` — merged like a final `.extras` call.
246
+ # Returns self so a stray trailing `.extras` chains harmlessly.
247
+ def to(outcome, extras = nil)
248
+ return self if @done
224
249
 
250
+ merge_extras(extras) unless extras.nil?
225
251
  @done = true
226
252
  @outcome = outcome.to_s
227
253
  begin
228
254
  @dispatch.call(
229
- Built.new(@problem, @subject || DEFAULT_SUBJECT, @outcome.empty? ? DEFAULT_OUTCOME : @outcome, @extras)
255
+ Built.new(@problem, @subject || DEFAULT_SUBJECT, @outcome.empty? ? DEFAULT_OUTCOME : @outcome, resolved_extras)
230
256
  )
231
257
  rescue StandardError
232
258
  # Reporting must never raise into caller code.
233
259
  nil
234
260
  end
261
+ self
262
+ end
263
+
264
+ private
265
+
266
+ def merge_extras(extras)
267
+ return unless extras.is_a?(Hash) && !extras.empty?
268
+
269
+ @extras = (@extras || {}).merge(extras)
270
+ end
271
+
272
+ # The chain's own extras merged OVER the ambient per-request buffer, so a
273
+ # chained key of the same name wins over an ambient one. Keys normalized
274
+ # to strings only when a merge actually happens; sanitize_extras
275
+ # stringifies the rest at build time.
276
+ def resolved_extras
277
+ ambient = Context.current
278
+ return @extras if ambient.empty?
279
+ return ambient if @extras.nil? || @extras.empty?
280
+
281
+ out = ambient.dup
282
+ @extras.each { |k, v| out[k.to_s] = v }
283
+ out
235
284
  end
236
285
  end
237
286
 
@@ -265,6 +314,8 @@ module Shipeasy
265
314
  end
266
315
 
267
316
  # A no-op chain returned by the module-level see() when no client exists.
317
+ # Every method returns self so the full grammar — including a trailing
318
+ # `.extras` after `.to` — chains without ever raising.
268
319
  class NullChain
269
320
  def causes_the(_subject)
270
321
  self
@@ -275,9 +326,54 @@ module Shipeasy
275
326
  self
276
327
  end
277
328
 
278
- def to(_outcome)
329
+ def to(_outcome, _extras = nil)
330
+ self
331
+ end
332
+ end
333
+
334
+ # ---- Ambient per-request extras -------------------------------------
335
+
336
+ # A per-request buffer of extras that merge into EVERY see() report firing
337
+ # later in the same execution context. Lets a request attach context
338
+ # (order id, route, tenant) from anywhere without threading it into the
339
+ # rescue block: `Shipeasy.add_extras(order_id: oid)` here, and any
340
+ # subsequent `see()` in this request carries it.
341
+ #
342
+ # Fiber-local (like AnonId), so concurrent requests never bleed into each
343
+ # other. The Rack middleware clears it in an `ensure` at the end of each
344
+ # request; outside a Rack request (jobs, scripts) call
345
+ # `Shipeasy.clear_extras` yourself when a logical unit of work ends.
346
+ #
347
+ # Values are stored raw and sanitized (scalar-only, truncated, 20-key cap,
348
+ # private-attribute stripped) at build time, exactly like chained extras.
349
+ module Context
350
+ THREAD_KEY = :shipeasy_see_ambient_extras
351
+
352
+ module_function
353
+
354
+ # Merge fields into the current context's buffer (string keys, later
355
+ # wins). Non-hash / empty input is ignored. Never raises.
356
+ def add(extras)
357
+ return unless extras.is_a?(Hash) && !extras.empty?
358
+
359
+ buf = (Thread.current[THREAD_KEY] ||= {})
360
+ extras.each { |k, v| buf[k.to_s] = v }
361
+ nil
362
+ rescue StandardError
279
363
  nil
280
364
  end
365
+
366
+ # A copy of the current context's buffer, or {} when empty.
367
+ def current
368
+ buf = Thread.current[THREAD_KEY]
369
+ buf.nil? || buf.empty? ? {} : buf.dup
370
+ end
371
+
372
+ # Drop the current context's buffer so extras never leak to the next
373
+ # request handled by this thread/fiber.
374
+ def clear
375
+ Thread.current[THREAD_KEY] = nil
376
+ end
281
377
  end
282
378
  end
283
379
  end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "3.1.1"
3
+ VERSION = "3.4.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -76,5 +76,34 @@ module Shipeasy
76
76
  def self.control_flow_exception(err)
77
77
  See::ControlFlowChain.new(err)
78
78
  end
79
+
80
+ # ---- ambient per-request see() extras -------------------------------
81
+ #
82
+ # Attach context that merges into every see() report firing later in this
83
+ # request, from anywhere — no need to thread it into the rescue block:
84
+ #
85
+ # Shipeasy::SDK.add_extras(order_id: order.id, tenant: tenant.slug)
86
+ # # ...later, somewhere else in the same request...
87
+ # rescue => e
88
+ # Shipeasy::SDK.see(e).causes_the("checkout").to("use cached prices")
89
+ # # ^ report carries order_id + tenant automatically
90
+ #
91
+ # Fiber-local, so concurrent requests never bleed. The Rack middleware
92
+ # clears the buffer per request (Rails auto-mounts it); outside Rack, call
93
+ # `clear_extras` when a unit of work ends. Accepts a hash and/or keywords.
94
+ # Works with no client configured (it only writes the buffer); never raises.
95
+ def self.add_extras(extras = nil, **kwargs)
96
+ merged = {}
97
+ merged.merge!(extras) if extras.is_a?(Hash)
98
+ merged.merge!(kwargs) unless kwargs.empty?
99
+ See::Context.add(merged)
100
+ nil
101
+ end
102
+
103
+ # Drop the ambient extras buffer for the current request/context.
104
+ def self.clear_extras
105
+ See::Context.clear
106
+ nil
107
+ end
79
108
  end
80
109
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 3.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-08 00:00:00.000000000 Z
11
+ date: 2026-07-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec