shipeasy-sdk 1.5.0 → 1.6.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: 73c3a9cf2fd1a6fb0a223b9db5bcbe13cd828bfd96a4e901de545ff56d254ce7
4
+ data.tar.gz: '0489c3e16d16592b1301e888c82fa1d5947c21c08c74adbd9eacb6db86420138'
5
5
  SHA512:
6
- metadata.gz: 93432ded1ed218653913a2895c0178741c4e4e06671e131722d45672f481d782791243fdc35d16347bd1462b2ea0cc2c8bdacdb7012d983a17f190c208159d9d
7
- data.tar.gz: def56c79ef372df20b3a7325e838d8165955c39a2c8221e393546d60b61b9405ee2d5a346e2a467bc771d88c50af8806f116d1bfaac2eaed5db9f2e8f6cbd338
6
+ metadata.gz: 9f2a91c5482d5eb3baf71e744116b7295ad50b19cea9a96c2ce6b6faa01467a38ca4db91f449ae773de76144156eaeec14052d14d12f3c68a3dc521fa1c6813b
7
+ data.tar.gz: b8e2512bbc85e404fa4528c411700bff22e7eea40f20be2d6392bd7f59166eb65464cfe17760f8b7a2f56c0eb2c7ca15a42fbc579c0937f04ce61c402824a862
@@ -6,6 +6,7 @@ require_relative "eval"
6
6
  require_relative "telemetry"
7
7
  require_relative "anon_id"
8
8
  require_relative "sticky_store"
9
+ require_relative "see"
9
10
 
10
11
  module Shipeasy
11
12
  module SDK
@@ -15,6 +16,9 @@ module Shipeasy
15
16
  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
17
  @api_key = api_key
17
18
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
19
+ # Read-env tag. Used by telemetry below and stamped onto see() error
20
+ # events so reports are attributable to an environment.
21
+ @env = env
18
22
  # Attribute names usable for targeting but stripped from every outbound
19
23
  # /collect payload (LD/Statsig privateAttributes). The server evaluates
20
24
  # locally so private attrs never leave for evaluation; the only egress is
@@ -55,6 +59,13 @@ module Shipeasy
55
59
  # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
56
60
  # @mutex; see on_change / notify_change.
57
61
  @change_listeners = []
62
+ # see() structured error reporting. Per-process spam guard, bound here so
63
+ # repeated reports of the same issue collapse to one send. See see.rb.
64
+ @see_limiter = See::Limiter.new
65
+ # Register as the default client backing the module-level Shipeasy::SDK
66
+ # .see/.see_violation funcs (last constructed wins — the server-SDK
67
+ # analog of TS's shipeasy({key}) configure call).
68
+ Shipeasy::SDK.set_default_client(self)
58
69
  end
59
70
 
60
71
  # Build a no-network, immediately-usable client for tests. Telemetry is
@@ -313,8 +324,58 @@ module Shipeasy
313
324
  end
314
325
  end
315
326
 
327
+ # ---- see() structured error reporting -------------------------------
328
+
329
+ # Report a caught exception (or thrown non-exception). Fire-and-forget;
330
+ # never blocks or throws into the request path. Terminate with
331
+ # `.to(outcome)`:
332
+ #
333
+ # client.see(e).causes_the("checkout").to("use cached prices")
334
+ def see(problem)
335
+ See::Chain.new(problem, method(:dispatch_see))
336
+ end
337
+
338
+ # Report a non-exception problem. The name is a stable fingerprint key —
339
+ # put variable data in `.extras`, never in the name.
340
+ def see_violation(name)
341
+ See::Chain.new(See::Violation.new(name), method(:dispatch_see))
342
+ end
343
+ alias seeViolation see_violation
344
+
345
+ # Mark an exception as expected control flow — reports nothing. Returns a
346
+ # `.because(reason)` tail (with optional `.extras` for local debug only).
347
+ def control_flow_exception(err)
348
+ See::ControlFlowChain.new(err)
349
+ end
350
+ alias controlFlowException control_flow_exception
351
+
316
352
  private
317
353
 
354
+ # Build the wire event and fire-and-forget POST it to /collect. No-op in
355
+ # test mode (mirrors track). Spam-guarded. Never raises into caller code.
356
+ def dispatch_see(built)
357
+ return if @test_mode
358
+
359
+ ev = See.build_event(
360
+ built.problem,
361
+ built.subject,
362
+ built.outcome,
363
+ strip_private(built.extras),
364
+ sdk_version: Shipeasy::SDK::VERSION,
365
+ env: @env,
366
+ )
367
+ return unless @see_limiter.should_send?(ev)
368
+
369
+ payload = JSON.generate({ events: [ev] })
370
+ Thread.new do
371
+ post("/collect", payload)
372
+ rescue => e
373
+ warn "[shipeasy] see() send failed: #{e.message}"
374
+ end
375
+ rescue => e
376
+ warn "[shipeasy] see() failed: #{e.message}"
377
+ end
378
+
318
379
  # Drop caller-marked private attributes from an outbound props bag. Handles
319
380
  # both string and symbol keys against the stringified private list.
320
381
  def strip_private(props)
@@ -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.6.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.6.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