shipeasy-sdk 3.3.0 → 3.5.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: 60cefd35bba21f86ef6b77da83d0c918744721d9c564066b12e8d2b95dba8740
4
- data.tar.gz: 22b03ac85fbd29bba861770120c2a78bbcbc5069e0e7af0cbe8b1c33b8ebee88
3
+ metadata.gz: 0453ca27510264bd268b40868bb5c55a942835b7a20076b11e19305b4eeb27a7
4
+ data.tar.gz: c9c0b1ee3c80cf28abac5af4ec8bdaec1613f8b3235708b79b27223e38d2d463
5
5
  SHA512:
6
- metadata.gz: 226eccc8d981ce982ed132eb697c39e37cde9f9d62d9f84970cea6119601696383520bc1f03606abd58b81574be31d26962aedf2e000acfe45e1ac7bdb2c5f2c
7
- data.tar.gz: 1e213dba4152299e0e0ad0d4480f02ca55c108d3fc7e5b70c966612799972a8b450fb24e5a74f4524e719d007994211fa903e6c5b76706012c9ba57f786b547e
6
+ metadata.gz: d92ea1c2016d7e81791a7d7b8d6a0cd631da001893227685297e570c86372dc905da89cf54641d85ef7715e6ffd5ae0498a8781714326352fc139cf5adae0320
7
+ data.tar.gz: bfd396eeac063d46053f15fc865f8444f8284dae480cee7f064809a43bb288cd48ebd7cf559d08d5d3f0f68c3252a360800a8a2f787c5b63e2a3c25601e8d555
data/docs/skill/SKILL.md CHANGED
@@ -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
 
@@ -150,6 +155,7 @@ the loader tag (public client key).
150
155
  - Anon bucketing: `Shipeasy::SDK::RackMiddleware` mints the shared `__se_anon_id`
151
156
  cookie (Rails Railtie auto-mounts it); anonymous `get_flag` then just works.
152
157
  - `c.private_attributes = ["email"]` strips keys from outbound events.
158
+ - `c.clean_backtrace` (default on) filters `see()` stacks to app frames via `Rails.backtrace_cleaner`; set `false` for raw backtraces.
153
159
  - `c.sticky_store = Shipeasy::SDK::InMemoryStickyStore.new` pins experiment assignment.
154
160
  - SSR: `Shipeasy.bootstrap_script_tag(user)` + `Shipeasy.i18n_script_tag(client_key, "en:prod")`.
155
161
  - `Shipeasy.on_change { ... }` (requires `c.poll = true`) fires after a poll fetches new data.
@@ -46,9 +46,14 @@ module Shipeasy
46
46
  # swallows one of its OWN internal errors it normally ships a structured
47
47
  # see event to Shipeasy's own project (NOT yours) so the SDK team can
48
48
  # track SDK bugs; set true to disable that entirely.
49
+ # - clean_backtrace (default true): pass see() error backtraces through the
50
+ # host framework's own backtrace cleaner so reports carry only your
51
+ # application frames (gem/framework noise stripped). We do not invent the
52
+ # filtering rules — this leverages `Rails.backtrace_cleaner` and is a
53
+ # no-op outside Rails. Set false to always report the raw backtrace.
49
54
  attr_accessor :env, :is_network_enabled, :disable_telemetry, :telemetry_url,
50
55
  :private_attributes, :sticky_store,
51
- :disable_internal_error_reporting
56
+ :disable_internal_error_reporting, :clean_backtrace
52
57
 
53
58
  # SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
54
59
  # One of :silent, :error, :warn (default), :info, :debug (strings accepted
@@ -103,6 +108,7 @@ module Shipeasy
103
108
  @is_network_enabled = nil
104
109
  @disable_telemetry = nil
105
110
  @disable_internal_error_reporting = false
111
+ @clean_backtrace = true
106
112
  @telemetry_url = nil
107
113
  @private_attributes = nil
108
114
  @sticky_store = nil
@@ -197,6 +203,7 @@ module Shipeasy
197
203
  sticky_store: cfg.sticky_store,
198
204
  log_level: cfg.log_level,
199
205
  disable_internal_error_reporting: cfg.disable_internal_error_reporting,
206
+ clean_backtrace: cfg.clean_backtrace,
200
207
  )
201
208
  @engine = engine
202
209
  # Capture +engine+ in the closure (not the @engine ivar, which a concurrent
@@ -326,6 +333,17 @@ module Shipeasy
326
333
  Shipeasy::SDK.control_flow_exception(err)
327
334
  end
328
335
 
336
+ # Ambient per-request see() extras — attach context from anywhere that
337
+ # merges into every see() report firing later in the same request, so you
338
+ # never thread it into the rescue block. See Shipeasy::SDK.add_extras.
339
+ def add_extras(extras = nil, **kwargs)
340
+ Shipeasy::SDK.add_extras(extras, **kwargs)
341
+ end
342
+
343
+ def clear_extras
344
+ Shipeasy::SDK.clear_extras
345
+ end
346
+
329
347
  # Replace the registered global engine + attributes transform (used by the
330
348
  # configure_for_* siblings — unlike configure, they replace so a test suite
331
349
  # can reconfigure between cases). Returns the engine.
@@ -36,7 +36,7 @@ module Shipeasy
36
36
  # /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
37
37
  DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
38
38
 
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)
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, clean_backtrace: true)
40
40
  # SDK-wide diagnostic verbosity. Set the leveled logger from the passed
41
41
  # level (default :warn; unknown falls back to :warn). The logger is
42
42
  # module-scoped, so the last-built engine wins — mirrors the TS SDK,
@@ -52,6 +52,13 @@ module Shipeasy
52
52
  # locally so private attrs never leave for evaluation; the only egress is
53
53
  # track(), where the listed keys are dropped from the props bag.
54
54
  @private_attributes = (private_attributes || []).map(&:to_s)
55
+ # When true (default), see() error stacks are passed through the host
56
+ # framework's own backtrace cleaner so a report carries only application
57
+ # frames (gem/framework noise stripped). We never invent the filtering —
58
+ # today this leverages Rails.backtrace_cleaner and is a no-op outside
59
+ # Rails. Set false to always send the raw backtrace. See
60
+ # see_backtrace_cleaner.
61
+ @clean_backtrace = clean_backtrace != false
55
62
  # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
56
63
  # Threaded into experiment eval so an enrolled unit locks to its first
57
64
  # assigned variant. Built-in: InMemoryStickyStore.
@@ -645,6 +652,7 @@ module Shipeasy
645
652
  strip_private(built.extras),
646
653
  sdk_version: Shipeasy::SDK::VERSION,
647
654
  env: @env,
655
+ backtrace_cleaner: see_backtrace_cleaner,
648
656
  )
649
657
  return unless @see_limiter.should_send?(ev)
650
658
 
@@ -658,6 +666,26 @@ module Shipeasy
658
666
  Shipeasy::Logging.error "[shipeasy] see() failed: #{e.message}"
659
667
  end
660
668
 
669
+ # The framework-provided backtrace cleaner used to strip gem/framework
670
+ # frames from see() stacks, or nil to send the raw backtrace. Resolved
671
+ # lazily (not memoized) because Rails installs its cleaner during boot,
672
+ # which can finish after Shipeasy.configure runs. Today the only supported
673
+ # cleaner is Rails' own `ActiveSupport::BacktraceCleaner` — we leverage it
674
+ # rather than reimplementing the app-vs-gem frame rules ourselves. Returns
675
+ # nil when disabled or when not running under Rails.
676
+ def see_backtrace_cleaner
677
+ return nil unless @clean_backtrace
678
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner)
679
+
680
+ cleaner = ::Rails.backtrace_cleaner
681
+ return nil unless cleaner.respond_to?(:clean)
682
+
683
+ ->(bt) { cleaner.clean(bt) }
684
+ rescue StandardError
685
+ # Rails present but the cleaner blew up while resolving: fall back to raw.
686
+ nil
687
+ end
688
+
661
689
  # Drop caller-marked private attributes from an outbound props bag. Handles
662
690
  # both string and symbol keys against the stringified private list.
663
691
  def strip_private(props)
@@ -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
 
@@ -110,7 +121,15 @@ module Shipeasy
110
121
  # ---- Wire event construction ----
111
122
 
112
123
  # Build the type:"error" event accepted by POST /collect.
113
- def build_event(problem, subject, outcome, extras, sdk_version:, env:)
124
+ #
125
+ # `backtrace_cleaner` (optional) is a callable that takes the raw backtrace
126
+ # array and returns an application-only subset (framework/gem frames
127
+ # stripped) — e.g. `->(bt) { Rails.backtrace_cleaner.clean(bt) }`. It is
128
+ # applied before the stack is joined; if it strips every frame (a stack
129
+ # that is purely framework/gem code) we fall back to the raw backtrace so
130
+ # an error is never left with an empty stack. We never invent the filtering
131
+ # rules — the callable is supplied by the framework's own cleaner.
132
+ def build_event(problem, subject, outcome, extras, sdk_version:, env:, backtrace_cleaner: nil)
114
133
  stack = nil
115
134
 
116
135
  if problem.is_a?(Violation)
@@ -120,7 +139,7 @@ module Shipeasy
120
139
  elsif problem.is_a?(Exception)
121
140
  error_type = problem.class.name || "Error"
122
141
  message = (problem.message.to_s.empty? ? error_type : problem.message)
123
- bt = problem.backtrace
142
+ bt = clean_backtrace(problem.backtrace, backtrace_cleaner)
124
143
  stack = bt.join("\n") if bt && !bt.empty?
125
144
  kind = "caught"
126
145
  else
@@ -147,6 +166,21 @@ module Shipeasy
147
166
  ev
148
167
  end
149
168
 
169
+ # Apply the framework-provided backtrace cleaner to a raw backtrace,
170
+ # returning an application-only frame array. No cleaner (or no backtrace)
171
+ # ⇒ the input is returned untouched. If the cleaner raises, or strips every
172
+ # frame, fall back to the raw backtrace — a cleaned-to-empty stack would
173
+ # lose all debugging signal for an error that lives entirely in framework
174
+ # code. Never raises.
175
+ def clean_backtrace(bt, cleaner)
176
+ return bt if cleaner.nil? || bt.nil? || bt.empty?
177
+
178
+ cleaned = cleaner.call(bt)
179
+ cleaned && !cleaned.empty? ? cleaned : bt
180
+ rescue StandardError
181
+ bt
182
+ end
183
+
150
184
  # ---- Spam limiter (mirror SeeLimiter) ----
151
185
 
152
186
  # Per-process spam guard: identical events within 30s collapse to one
@@ -211,27 +245,65 @@ module Shipeasy
211
245
  end
212
246
  alias causesThe causes_the
213
247
 
248
+ # Attach debugging metadata. Chainable — call repeatedly (keys merge,
249
+ # later wins) *before* `.to`. Called after `.to` it is a no-op with a
250
+ # warning: the report already shipped, so there is nothing to amend and,
251
+ # crucially, it must not raise into the caller's rescue block. Use
252
+ # `.to(outcome, extras)` or `Shipeasy.add_extras` for late/scattered
253
+ # context instead.
214
254
  def extras(extras)
215
- if extras.is_a?(Hash) && !extras.empty?
216
- @extras = (@extras || {}).merge(extras)
255
+ if @done
256
+ Shipeasy::Logging.warn(
257
+ "[shipeasy] see() .extras(...) called after .to(...) is ignored — " \
258
+ "pass extras to .to(outcome, extras) or call .extras before .to"
259
+ )
260
+ return self
217
261
  end
262
+ merge_extras(extras)
218
263
  self
219
264
  end
220
265
 
221
266
  # Terminal: build the event and fire-and-forget the report. Idempotent.
222
- def to(outcome)
223
- return if @done
267
+ # `extras` may be passed inline here as the trailing form
268
+ # `.to(outcome, { order_id: oid })` — merged like a final `.extras` call.
269
+ # Returns self so a stray trailing `.extras` chains harmlessly.
270
+ def to(outcome, extras = nil)
271
+ return self if @done
224
272
 
273
+ merge_extras(extras) unless extras.nil?
225
274
  @done = true
226
275
  @outcome = outcome.to_s
227
276
  begin
228
277
  @dispatch.call(
229
- Built.new(@problem, @subject || DEFAULT_SUBJECT, @outcome.empty? ? DEFAULT_OUTCOME : @outcome, @extras)
278
+ Built.new(@problem, @subject || DEFAULT_SUBJECT, @outcome.empty? ? DEFAULT_OUTCOME : @outcome, resolved_extras)
230
279
  )
231
280
  rescue StandardError
232
281
  # Reporting must never raise into caller code.
233
282
  nil
234
283
  end
284
+ self
285
+ end
286
+
287
+ private
288
+
289
+ def merge_extras(extras)
290
+ return unless extras.is_a?(Hash) && !extras.empty?
291
+
292
+ @extras = (@extras || {}).merge(extras)
293
+ end
294
+
295
+ # The chain's own extras merged OVER the ambient per-request buffer, so a
296
+ # chained key of the same name wins over an ambient one. Keys normalized
297
+ # to strings only when a merge actually happens; sanitize_extras
298
+ # stringifies the rest at build time.
299
+ def resolved_extras
300
+ ambient = Context.current
301
+ return @extras if ambient.empty?
302
+ return ambient if @extras.nil? || @extras.empty?
303
+
304
+ out = ambient.dup
305
+ @extras.each { |k, v| out[k.to_s] = v }
306
+ out
235
307
  end
236
308
  end
237
309
 
@@ -265,6 +337,8 @@ module Shipeasy
265
337
  end
266
338
 
267
339
  # A no-op chain returned by the module-level see() when no client exists.
340
+ # Every method returns self so the full grammar — including a trailing
341
+ # `.extras` after `.to` — chains without ever raising.
268
342
  class NullChain
269
343
  def causes_the(_subject)
270
344
  self
@@ -275,8 +349,53 @@ module Shipeasy
275
349
  self
276
350
  end
277
351
 
278
- def to(_outcome)
352
+ def to(_outcome, _extras = nil)
353
+ self
354
+ end
355
+ end
356
+
357
+ # ---- Ambient per-request extras -------------------------------------
358
+
359
+ # A per-request buffer of extras that merge into EVERY see() report firing
360
+ # later in the same execution context. Lets a request attach context
361
+ # (order id, route, tenant) from anywhere without threading it into the
362
+ # rescue block: `Shipeasy.add_extras(order_id: oid)` here, and any
363
+ # subsequent `see()` in this request carries it.
364
+ #
365
+ # Fiber-local (like AnonId), so concurrent requests never bleed into each
366
+ # other. The Rack middleware clears it in an `ensure` at the end of each
367
+ # request; outside a Rack request (jobs, scripts) call
368
+ # `Shipeasy.clear_extras` yourself when a logical unit of work ends.
369
+ #
370
+ # Values are stored raw and sanitized (scalar-only, truncated, 20-key cap,
371
+ # private-attribute stripped) at build time, exactly like chained extras.
372
+ module Context
373
+ THREAD_KEY = :shipeasy_see_ambient_extras
374
+
375
+ module_function
376
+
377
+ # Merge fields into the current context's buffer (string keys, later
378
+ # wins). Non-hash / empty input is ignored. Never raises.
379
+ def add(extras)
380
+ return unless extras.is_a?(Hash) && !extras.empty?
381
+
382
+ buf = (Thread.current[THREAD_KEY] ||= {})
383
+ extras.each { |k, v| buf[k.to_s] = v }
279
384
  nil
385
+ rescue StandardError
386
+ nil
387
+ end
388
+
389
+ # A copy of the current context's buffer, or {} when empty.
390
+ def current
391
+ buf = Thread.current[THREAD_KEY]
392
+ buf.nil? || buf.empty? ? {} : buf.dup
393
+ end
394
+
395
+ # Drop the current context's buffer so extras never leak to the next
396
+ # request handled by this thread/fiber.
397
+ def clear
398
+ Thread.current[THREAD_KEY] = nil
280
399
  end
281
400
  end
282
401
  end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "3.3.0"
3
+ VERSION = "3.5.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.3.0
4
+ version: 3.5.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-10 00:00:00.000000000 Z
11
+ date: 2026-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec