shipeasy-sdk 1.5.0 → 1.7.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: ff3dbc0f1f6f19484edafcb75460928392fd43278036b68ed2c8179f9d21ac4d
4
- data.tar.gz: 04d7e38426d7be6c00e504749c6a33441d88db79f70e1eae533fa9fc115b94d4
3
+ metadata.gz: afdce684e3c6a83b220b7dfaa1d7db249284e3bb4c4ebfe3c9b27c54da0d801b
4
+ data.tar.gz: ecdfdf8aed19f1b5f18fe874ce5b13064bb61abe7bf7f1c47a76b08d9ab99bce
5
5
  SHA512:
6
- metadata.gz: 93432ded1ed218653913a2895c0178741c4e4e06671e131722d45672f481d782791243fdc35d16347bd1462b2ea0cc2c8bdacdb7012d983a17f190c208159d9d
7
- data.tar.gz: def56c79ef372df20b3a7325e838d8165955c39a2c8221e393546d60b61b9405ee2d5a346e2a467bc771d88c50af8806f116d1bfaac2eaed5db9f2e8f6cbd338
6
+ metadata.gz: e6a5470268bc584eaa2dbc8c89f722a22633d0bd6f896aaa1d73db74090b56667c87733c614b8a49bdeda88dab043740509153503943837a69be20a03dc315b7
7
+ data.tar.gz: 55289a4adf0559f41eca2d1f6bca03b53a19da9b3f00f4109dc4476ed0c5cbaaed453dbaf3f7c0ebcc1405f0d73a55fe702e02859c9a809d525e92b294e275ba
data/README.md CHANGED
@@ -115,6 +115,31 @@ client.init
115
115
  at_exit { client.destroy }
116
116
  ```
117
117
 
118
+ ## Server-side rendering (SSR)
119
+
120
+ Emit the request's evaluated flags as a declarative `<script>` tag so the
121
+ browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
122
+ in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
123
+ hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
124
+ browser buckets identically to the server.
125
+
126
+ ```ruby
127
+ user = { "user_id" => "u_123" }
128
+
129
+ # Two tags for the document <head>. The PUBLIC client key (not the server
130
+ # key) goes on the i18n loader tag.
131
+ head = client.bootstrap_script_tag(user, anon_id: anon_id) +
132
+ client.i18n_script_tag(client_key, profile: "en:prod")
133
+
134
+ # …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
135
+ boot = client.evaluate(user)
136
+ ```
137
+
138
+ `bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
139
+ `https://cdn.shipeasy.ai`). In **Rails**, the existing
140
+ `Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
141
+ loader tag from your app config.
142
+
118
143
  ## Default values
119
144
 
120
145
  `get_flag` and `get_config` take an optional `default:` returned **only when the
@@ -2,19 +2,27 @@ require "net/http"
2
2
  require "uri"
3
3
  require "json"
4
4
  require "thread"
5
+ require "cgi"
5
6
  require_relative "eval"
6
7
  require_relative "telemetry"
7
8
  require_relative "anon_id"
8
9
  require_relative "sticky_store"
10
+ require_relative "see"
9
11
 
10
12
  module Shipeasy
11
13
  module SDK
12
14
  class FlagsClient
13
15
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
16
+ # CDN origin serving the static loader scripts (/sdk/bootstrap.js,
17
+ # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
18
+ DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
14
19
 
15
20
  def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil)
16
21
  @api_key = api_key
17
22
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
23
+ # Read-env tag. Used by telemetry below and stamped onto see() error
24
+ # events so reports are attributable to an environment.
25
+ @env = env
18
26
  # Attribute names usable for targeting but stripped from every outbound
19
27
  # /collect payload (LD/Statsig privateAttributes). The server evaluates
20
28
  # locally so private attrs never leave for evaluation; the only egress is
@@ -55,6 +63,13 @@ module Shipeasy
55
63
  # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
56
64
  # @mutex; see on_change / notify_change.
57
65
  @change_listeners = []
66
+ # see() structured error reporting. Per-process spam guard, bound here so
67
+ # repeated reports of the same issue collapse to one send. See see.rb.
68
+ @see_limiter = See::Limiter.new
69
+ # Register as the default client backing the module-level Shipeasy::SDK
70
+ # .see/.see_violation funcs (last constructed wins — the server-SDK
71
+ # analog of TS's shipeasy({key}) configure call).
72
+ Shipeasy::SDK.set_default_client(self)
58
73
  end
59
74
 
60
75
  # Build a no-network, immediately-usable client for tests. Telemetry is
@@ -260,6 +275,71 @@ module Shipeasy
260
275
  result
261
276
  end
262
277
 
278
+ # Batch-evaluate every loaded gate, config and experiment for +user+ into
279
+ # a bootstrap payload (+{ "flags" => ..., "configs" => ..., "experiments"
280
+ # => ..., "killswitches" => ... }+) keyed to match the browser SDK's
281
+ # window.__SE_BOOTSTRAP shape. Local overrides win. Killswitches are folded
282
+ # into per-gate evaluation, so the standalone +killswitches+ map is empty
283
+ # for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
284
+ def evaluate(user)
285
+ u = with_anon_id(user)
286
+ flags_blob, exps_blob, flag_ov, config_ov, exp_ov, sticky = @mutex.synchronize do
287
+ [@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup,
288
+ @exp_overrides.dup, @sticky_store]
289
+ end
290
+
291
+ flags = {}
292
+ (flags_blob&.dig("gates") || {}).each do |name, gate|
293
+ flags[name] = flag_ov.key?(name) ? flag_ov[name] : Eval.eval_gate(gate, u)
294
+ end
295
+
296
+ configs = {}
297
+ (flags_blob&.dig("configs") || {}).each do |name, entry|
298
+ configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
299
+ end
300
+
301
+ experiments = {}
302
+ (exps_blob&.dig("experiments") || {}).each do |name, exp|
303
+ if exp_ov.key?(name)
304
+ ov = exp_ov[name]
305
+ experiments[name] = { "inExperiment" => true, "group" => ov[:group], "params" => ov[:params] }
306
+ next
307
+ end
308
+ r = Eval.eval_experiment(exp, flags_blob, exps_blob, u, exp_name: name, sticky_store: sticky)
309
+ experiments[name] = { "inExperiment" => r.in_experiment, "group" => r.group, "params" => r.params }
310
+ end
311
+
312
+ { "flags" => flags, "configs" => configs, "experiments" => experiments, "killswitches" => {} }
313
+ end
314
+
315
+ # Return the cross-platform SSR bootstrap <script> tag for a request:
316
+ # se-bootstrap.js reads its data-* attributes and hydrates
317
+ # window.__SE_BOOTSTRAP (and writes the anon cookie). No key is embedded.
318
+ def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
319
+ payload = evaluate(user)
320
+ base = cdn_base(base_url)
321
+ attrs = [
322
+ "data-se-bootstrap",
323
+ attr("data-flags", JSON.generate(payload["flags"])),
324
+ attr("data-configs", JSON.generate(payload["configs"])),
325
+ attr("data-experiments", JSON.generate(payload["experiments"])),
326
+ attr("data-killswitches", JSON.generate(payload["killswitches"])),
327
+ attr("data-i18n-profile", i18n_profile || "en:prod"),
328
+ attr("data-api-url", base),
329
+ ]
330
+ attrs << attr("data-anon-id", anon_id) if anon_id && !anon_id.empty?
331
+ %(<script src="#{CGI.escapeHTML("#{base}/sdk/bootstrap.js")}" #{attrs.join(' ')}></script>)
332
+ end
333
+
334
+ # Return the i18n loader <script> tag (framework-agnostic; the Rails view
335
+ # helper Shipeasy::I18n::ViewHelpers#i18n_script_tag is separate). The
336
+ # loader fetches translations for the profile using the PUBLIC client key.
337
+ def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
338
+ base = cdn_base(base_url)
339
+ %(<script src="#{CGI.escapeHTML("#{base}/sdk/i18n/loader.js")}" ) +
340
+ %(#{attr('data-key', client_key)} #{attr('data-profile', profile || 'en:prod')}></script>)
341
+ end
342
+
263
343
  def track(user_id, event_name, props = {})
264
344
  return if @test_mode
265
345
 
@@ -313,8 +393,58 @@ module Shipeasy
313
393
  end
314
394
  end
315
395
 
396
+ # ---- see() structured error reporting -------------------------------
397
+
398
+ # Report a caught exception (or thrown non-exception). Fire-and-forget;
399
+ # never blocks or throws into the request path. Terminate with
400
+ # `.to(outcome)`:
401
+ #
402
+ # client.see(e).causes_the("checkout").to("use cached prices")
403
+ def see(problem)
404
+ See::Chain.new(problem, method(:dispatch_see))
405
+ end
406
+
407
+ # Report a non-exception problem. The name is a stable fingerprint key —
408
+ # put variable data in `.extras`, never in the name.
409
+ def see_violation(name)
410
+ See::Chain.new(See::Violation.new(name), method(:dispatch_see))
411
+ end
412
+ alias seeViolation see_violation
413
+
414
+ # Mark an exception as expected control flow — reports nothing. Returns a
415
+ # `.because(reason)` tail (with optional `.extras` for local debug only).
416
+ def control_flow_exception(err)
417
+ See::ControlFlowChain.new(err)
418
+ end
419
+ alias controlFlowException control_flow_exception
420
+
316
421
  private
317
422
 
423
+ # Build the wire event and fire-and-forget POST it to /collect. No-op in
424
+ # test mode (mirrors track). Spam-guarded. Never raises into caller code.
425
+ def dispatch_see(built)
426
+ return if @test_mode
427
+
428
+ ev = See.build_event(
429
+ built.problem,
430
+ built.subject,
431
+ built.outcome,
432
+ strip_private(built.extras),
433
+ sdk_version: Shipeasy::SDK::VERSION,
434
+ env: @env,
435
+ )
436
+ return unless @see_limiter.should_send?(ev)
437
+
438
+ payload = JSON.generate({ events: [ev] })
439
+ Thread.new do
440
+ post("/collect", payload)
441
+ rescue => e
442
+ warn "[shipeasy] see() send failed: #{e.message}"
443
+ end
444
+ rescue => e
445
+ warn "[shipeasy] see() failed: #{e.message}"
446
+ end
447
+
318
448
  # Drop caller-marked private attributes from an outbound props bag. Handles
319
449
  # both string and symbol keys against the stringified private list.
320
450
  def strip_private(props)
@@ -366,6 +496,14 @@ module Shipeasy
366
496
  v.nil? || v == ""
367
497
  end
368
498
 
499
+ def cdn_base(override)
500
+ (override && !override.empty? ? override : DEFAULT_CDN_BASE).chomp("/")
501
+ end
502
+
503
+ def attr(name, value)
504
+ %(#{name}="#{CGI.escapeHTML(value.to_s)}")
505
+ end
506
+
369
507
  def start_poll
370
508
  @timer = Thread.new do
371
509
  loop do
@@ -0,0 +1,284 @@
1
+ # see — shipeasy error. Structured error reporting for the server SDK.
2
+ #
3
+ # Mirrors `@shipeasy/sdk` (packages/ts-sdk/src/see/core.ts) and the Python
4
+ # reference (packages/server-sdks/sdk-python/shipeasy/_see.py). Every handled
5
+ # exception documents its product *consequence*, not just its stack:
6
+ #
7
+ # begin
8
+ # charge_card(order)
9
+ # rescue => e
10
+ # Shipeasy::SDK.see(e).causes_the("checkout").to("use the backup processor")
11
+ # end
12
+ #
13
+ # Dispatch model (differs from TS, which uses a microtask): `.to(outcome)` is
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`:
17
+ #
18
+ # client.see(e).causes_the("checkout").to("use cached prices")
19
+ # client.see(e).causes_the("checkout").extras({ order_id: oid }).to("use cached prices")
20
+ #
21
+ # If you don't know the consequence of an exception, don't catch it.
22
+
23
+ require "thread"
24
+ require "json"
25
+ require_relative "version"
26
+
27
+ module Shipeasy
28
+ module SDK
29
+ module See
30
+ # ---- Limits (mirror core.ts; kept in sync with the worker's /collect) ----
31
+ SEE_MAX_MESSAGE = 500
32
+ SEE_MAX_STACK = 8000
33
+ SEE_MAX_SUBJECT = 200 # used for subject, outcome, error_type
34
+ SEE_MAX_EXTRA_VALUE = 200
35
+ SEE_MAX_EXTRA_KEYS = 20
36
+ SEE_DEDUP_WINDOW_MS = 30_000
37
+ SEE_MAX_PER_PROCESS = 25
38
+
39
+ # Default consequence parts when a chain omits them.
40
+ DEFAULT_SUBJECT = "app".freeze
41
+ DEFAULT_OUTCOME = "hit an error".freeze
42
+
43
+ # Marker attribute stamped onto an exception by control_flow_exception().
44
+ EXPECTED_IVAR = :@__shipeasy_see_expected
45
+
46
+ module_function
47
+
48
+ def truncate(str, limit)
49
+ s = str.to_s
50
+ s.length <= limit ? s : s[0, limit]
51
+ end
52
+
53
+ # Drop nil values, keep only String/Numeric(finite)/boolean, truncate
54
+ # string values to 200 chars, cap at 20 keys (insertion order). Returns
55
+ # nil if nothing is kept. Keys are stringified.
56
+ def sanitize_extras(extras)
57
+ return nil unless extras.is_a?(Hash)
58
+ return nil if extras.empty?
59
+
60
+ out = {}
61
+ extras.each do |k, v|
62
+ break if out.size >= SEE_MAX_EXTRA_KEYS
63
+ next if v.nil?
64
+
65
+ case v
66
+ when true, false
67
+ out[k.to_s] = v
68
+ when String
69
+ out[k.to_s] = truncate(v, SEE_MAX_EXTRA_VALUE)
70
+ when Numeric
71
+ # Reject NaN / Infinity (not representable in JSON).
72
+ next if v.respond_to?(:finite?) && !v.finite?
73
+
74
+ out[k.to_s] = v
75
+ else
76
+ next
77
+ end
78
+ end
79
+ out.empty? ? nil : out
80
+ end
81
+
82
+ # Best-effort stamp marking an exception as expected control flow.
83
+ def mark_expected(err, because, extras = nil)
84
+ mark = { "because" => because.to_s }
85
+ clean = sanitize_extras(extras)
86
+ mark["extras"] = clean if clean
87
+ err.instance_variable_set(EXPECTED_IVAR, mark)
88
+ rescue StandardError
89
+ # Frozen / builtin objects that reject ivars: best effort only.
90
+ nil
91
+ end
92
+
93
+ def expected?(err)
94
+ err.instance_variable_defined?(EXPECTED_IVAR) &&
95
+ !err.instance_variable_get(EXPECTED_IVAR).nil?
96
+ rescue StandardError
97
+ false
98
+ end
99
+
100
+ # A non-exception problem. The name is a stable fingerprint key — put
101
+ # variable data in `.extras`, never in the name.
102
+ class Violation
103
+ attr_reader :name
104
+
105
+ def initialize(name)
106
+ @name = name.to_s
107
+ end
108
+ end
109
+
110
+ # ---- Wire event construction ----
111
+
112
+ # Build the type:"error" event accepted by POST /collect.
113
+ def build_event(problem, subject, outcome, extras, sdk_version:, env:)
114
+ stack = nil
115
+
116
+ if problem.is_a?(Violation)
117
+ error_type = problem.name
118
+ message = problem.name
119
+ kind = "violation"
120
+ elsif problem.is_a?(Exception)
121
+ error_type = problem.class.name || "Error"
122
+ message = (problem.message.to_s.empty? ? error_type : problem.message)
123
+ bt = problem.backtrace
124
+ stack = bt.join("\n") if bt && !bt.empty?
125
+ kind = "caught"
126
+ else
127
+ error_type = "Error"
128
+ message = problem.to_s
129
+ kind = "caught"
130
+ end
131
+
132
+ ev = {
133
+ "type" => "error",
134
+ "kind" => kind,
135
+ "error_type" => truncate(error_type, SEE_MAX_SUBJECT),
136
+ "message" => truncate(message, SEE_MAX_MESSAGE),
137
+ "subject" => truncate(subject, SEE_MAX_SUBJECT),
138
+ "outcome" => truncate(outcome, SEE_MAX_SUBJECT),
139
+ "side" => "server",
140
+ "sdk_version" => sdk_version,
141
+ "ts" => (Time.now.to_f * 1000).to_i,
142
+ }
143
+ ev["stack"] = truncate(stack, SEE_MAX_STACK) if stack
144
+ clean = sanitize_extras(extras)
145
+ ev["extras"] = clean if clean
146
+ ev["env"] = env if env && !env.to_s.empty?
147
+ ev
148
+ end
149
+
150
+ # ---- Spam limiter (mirror SeeLimiter) ----
151
+
152
+ # Per-process spam guard: identical events within 30s collapse to one
153
+ # send; a hard cap bounds total sends. Thread-safe. The worker dedupes by
154
+ # fingerprint anyway — this only bounds network chatter from a hot loop.
155
+ class Limiter
156
+ def initialize(max_per_process: SEE_MAX_PER_PROCESS, dedup_window_ms: SEE_DEDUP_WINDOW_MS)
157
+ @max = max_per_process
158
+ @window = dedup_window_ms
159
+ @last = {}
160
+ @sent = 0
161
+ @mutex = Mutex.new
162
+ end
163
+
164
+ def should_send?(ev)
165
+ @mutex.synchronize do
166
+ return false if @sent >= @max
167
+
168
+ key = [
169
+ ev["kind"],
170
+ ev["error_type"],
171
+ ev["message"].to_s[0, 200],
172
+ See.top_stack_line(ev["stack"]),
173
+ ].join("|")
174
+ now = (Time.now.to_f * 1000).to_i
175
+ prev = @last[key]
176
+ return false if prev && (now - prev) < @window
177
+
178
+ @last[key] = now
179
+ @sent += 1
180
+ true
181
+ end
182
+ end
183
+ end
184
+
185
+ def top_stack_line(stack)
186
+ return "" if stack.nil? || stack.empty?
187
+
188
+ stack.each_line do |line|
189
+ s = line.strip
190
+ return s[0, 200] if s.start_with?("File ") || s.start_with?("at ") || s.include?("line ") || s.include?(":in ")
191
+ end
192
+ ""
193
+ end
194
+
195
+ # ---- Fluent chains ----
196
+
197
+ # Accumulates consequence + extras; `.to(outcome)` dispatches once.
198
+ class Chain
199
+ def initialize(problem, dispatch)
200
+ @problem = problem
201
+ @dispatch = dispatch
202
+ @subject = nil
203
+ @outcome = nil
204
+ @extras = nil
205
+ @done = false
206
+ end
207
+
208
+ def causes_the(subject)
209
+ @subject = subject.to_s
210
+ self
211
+ end
212
+ alias causesThe causes_the
213
+
214
+ def extras(extras)
215
+ if extras.is_a?(Hash) && !extras.empty?
216
+ @extras = (@extras || {}).merge(extras)
217
+ end
218
+ self
219
+ end
220
+
221
+ # Terminal: build the event and fire-and-forget the report. Idempotent.
222
+ def to(outcome)
223
+ return if @done
224
+
225
+ @done = true
226
+ @outcome = outcome.to_s
227
+ begin
228
+ @dispatch.call(
229
+ Built.new(@problem, @subject || DEFAULT_SUBJECT, @outcome.empty? ? DEFAULT_OUTCOME : @outcome, @extras)
230
+ )
231
+ rescue StandardError
232
+ # Reporting must never raise into caller code.
233
+ nil
234
+ end
235
+ end
236
+ end
237
+
238
+ # Plain carrier of a finalized chain handed to the client dispatcher.
239
+ Built = Struct.new(:problem, :subject, :outcome, :extras)
240
+
241
+ # `control_flow_exception(e).because("because ...")` — marks the exception
242
+ # expected and reports NOTHING. `.extras` is stored for local debugging
243
+ # only (an expected exception is never transmitted).
244
+ class ControlFlowChain
245
+ def initialize(err)
246
+ @err = err
247
+ end
248
+
249
+ def because(reason)
250
+ See.mark_expected(@err, reason)
251
+ ControlFlowTail.new(@err, reason)
252
+ end
253
+ end
254
+
255
+ class ControlFlowTail
256
+ def initialize(err, reason)
257
+ @err = err
258
+ @reason = reason
259
+ end
260
+
261
+ def extras(extras)
262
+ See.mark_expected(@err, @reason, extras)
263
+ self
264
+ end
265
+ end
266
+
267
+ # A no-op chain returned by the module-level see() when no client exists.
268
+ class NullChain
269
+ def causes_the(_subject)
270
+ self
271
+ end
272
+ alias causesThe causes_the
273
+
274
+ def extras(_extras)
275
+ self
276
+ end
277
+
278
+ def to(_outcome)
279
+ nil
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.5.0"
3
+ VERSION = "1.7.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -25,5 +25,54 @@ module Shipeasy
25
25
  def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
26
26
  FlagsClient.new(api_key: api_key, base_url: base_url)
27
27
  end
28
+
29
+ # ---- see() module-level facade --------------------------------------
30
+ #
31
+ # Backed by a default client, registered when a FlagsClient is constructed
32
+ # (last constructed wins). Mirrors the package-level see() in the TS/Python
33
+ # SDKs so callers can `Shipeasy::SDK.see(e).causes_the(...).to(...)` without
34
+ # threading a client reference through every call site. A call before any
35
+ # client exists warns and returns a no-op chain (NEVER raises).
36
+
37
+ @see_default_client = nil
38
+ @see_default_mutex = Mutex.new
39
+
40
+ # Register the client backing the module-level see() funcs. Called
41
+ # automatically from FlagsClient#initialize; also exposed for explicit use.
42
+ def self.set_default_client(client)
43
+ @see_default_mutex.synchronize { @see_default_client = client }
44
+ client
45
+ end
46
+
47
+ def self.default_client
48
+ @see_default_mutex.synchronize { @see_default_client }
49
+ end
50
+
51
+ # Report a caught exception via the default client. Use client.see to
52
+ # target a specific client.
53
+ def self.see(problem)
54
+ client = default_client
55
+ if client.nil?
56
+ warn "[shipeasy] see() called before a client was created — error dropped"
57
+ return See::NullChain.new
58
+ end
59
+ client.see(problem)
60
+ end
61
+
62
+ # Report a non-exception problem via the default client.
63
+ def self.see_violation(name)
64
+ client = default_client
65
+ if client.nil?
66
+ warn "[shipeasy] see_violation() called before a client was created — error dropped"
67
+ return See::NullChain.new
68
+ end
69
+ client.see_violation(name)
70
+ end
71
+
72
+ # Mark an exception as expected control flow (reports nothing). Works
73
+ # without a client — it only stamps the exception object.
74
+ def self.control_flow_exception(err)
75
+ See::ControlFlowChain.new(err)
76
+ end
28
77
  end
29
78
  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: 1.5.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shipeasy, Inc.
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-19 00:00:00.000000000 Z
11
+ date: 2026-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec
@@ -76,6 +76,7 @@ files:
76
76
  - lib/shipeasy/sdk/openfeature.rb
77
77
  - lib/shipeasy/sdk/rack_middleware.rb
78
78
  - lib/shipeasy/sdk/railtie.rb
79
+ - lib/shipeasy/sdk/see.rb
79
80
  - lib/shipeasy/sdk/sticky_store.rb
80
81
  - lib/shipeasy/sdk/telemetry.rb
81
82
  - lib/shipeasy/sdk/version.rb