shipeasy-sdk 3.4.0 → 3.5.1

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: c1b1f478bea3279f979e85e04476381f719d3db27aa29ffb23447768ceefc560
4
- data.tar.gz: c0d4f8f1ca49a6a1b34ac3b1198a3788dc115f640eb5845fb388a66175e1384c
3
+ metadata.gz: 936c7071cc2d4302c9523145eaa5ee80b184167bea8047ee7be9f539d2a3538e
4
+ data.tar.gz: ae302e9e648ed16fcf4687ad90668779434a9fda602266e0a4d7fc6a319bda81
5
5
  SHA512:
6
- metadata.gz: 94790b0a88b1524f35faf706245339db83908565b5adec3a7f8b9f5775d476cb703ce00ac55a697d79a343a1abe0c94145c2fd693538c1aecbdab81385525148
7
- data.tar.gz: f35b460dd560167674a1a278c1e76600fd59040016ad06b3129ee77c5dd95d68d57d0c7695ca06dee7f7870f15489655ee20ca644b6d1a24d416fdf280c2daf7
6
+ metadata.gz: eab22b5a957b8be557fd605f3e19024872217179b43d27d1356caec5f1c6829f2049a6ca4663bf53f58ff31dfc92bdacdbf183655b160fc653aa74d916d81304
7
+ data.tar.gz: 3a79b37580852b152b783dc3265232ebd123aac3176ea82b8d5a3acdd3c1285ff2163d2b682180f20005ccf0d702d862b2c7bf1cc5c6a4dcc3ac739613d527ff
data/docs/skill/SKILL.md CHANGED
@@ -155,6 +155,7 @@ the loader tag (public client key).
155
155
  - Anon bucketing: `Shipeasy::SDK::RackMiddleware` mints the shared `__se_anon_id`
156
156
  cookie (Rails Railtie auto-mounts it); anonymous `get_flag` then just works.
157
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.
158
159
  - `c.sticky_store = Shipeasy::SDK::InMemoryStickyStore.new` pins experiment assignment.
159
160
  - SSR: `Shipeasy.bootstrap_script_tag(user)` + `Shipeasy.i18n_script_tag(client_key, "en:prod")`.
160
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
@@ -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)
@@ -70,10 +70,102 @@ module Shipeasy
70
70
  user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
71
71
  end
72
72
 
73
+ def self.clamp_pct(n)
74
+ return 0 if n < 0
75
+ return 10000 if n > 10000
76
+ n
77
+ end
78
+
79
+ # Effective rollout % for a stack entry at time +now+ (epoch ms). A
80
+ # condition with no explicit rolloutPct defaults to 100% (match => pass); a
81
+ # rollout to 0%. A ramp linearly interpolates from=>to over
82
+ # [startAt, startAt + durationMs] via truncating-toward-zero division — the
83
+ # cross-SDK contract (experiment-platform/04-evaluation.md). Mirrors core's
84
+ # effectivePct.
85
+ def self.effective_pct(entry, now)
86
+ type = entry["type"] || entry[:type]
87
+ raw = entry["rolloutPct"] || entry[:rolloutPct]
88
+ base = raw.nil? ? (type == "condition" ? 10000 : 0) : raw
89
+ ramp = entry["ramp"] || entry[:ramp]
90
+ return base unless ramp
91
+
92
+ from = ramp["from"] || ramp[:from]
93
+ to = ramp["to"] || ramp[:to]
94
+ start_at = ramp["startAt"] || ramp[:startAt]
95
+ duration = ramp["durationMs"] || ramp[:durationMs]
96
+ return from if now <= start_at
97
+ return to if now >= start_at + duration
98
+
99
+ delta = to - from # signed
100
+ elapsed = now - start_at
101
+ # .to_i on the float quotient truncates toward zero (works for a negative
102
+ # ramp-down delta, unlike Ruby's floor-division integer `/`).
103
+ clamp_pct(from + (delta * elapsed).fdiv(duration).to_i)
104
+ end
105
+
106
+ # Hash the caller into [0, 10000) and test against +pct+. No-unit contract
107
+ # (experiment-platform/18): a fully-rolled bucket is on for everyone without
108
+ # a unit id; a fractional one needs a stable unit, so it is off. Mirrors
109
+ # core's bucketHit.
110
+ def self.bucket_hit(pct, uid, salt)
111
+ return false if pct <= 0
112
+ return pct >= 10000 if uid.nil? || uid.to_s.empty?
113
+ return true if pct >= 10000
114
+ murmur3("#{salt}:#{uid}") % 10000 < pct
115
+ end
116
+
117
+ # Evaluate one gatekeeper stack entry. A `condition` gates on its rules
118
+ # (pass: "all" | "any") then buckets at its own rollout % (default 100%); a
119
+ # `rollout` buckets everyone who reached it (default 0%). A condition's
120
+ # default bucketing salt is its own id so each step buckets independently;
121
+ # a rollout falls back to the gate salt so existing entries don't re-bucket.
122
+ # Mirrors core's evalStackEntry.
123
+ def self.eval_stack_entry(entry, user, fallback_salt, now)
124
+ type = entry["type"] || entry[:type]
125
+ bucket_by = entry["bucketBy"] || entry[:bucketBy]
126
+ entry_salt = entry["salt"] || entry[:salt]
127
+
128
+ if type == "condition"
129
+ rules = entry["rules"] || entry[:rules] || []
130
+ return false if rules.empty?
131
+
132
+ mode = entry["pass"] || entry[:pass] || "all"
133
+ matched =
134
+ if mode == "any"
135
+ rules.any? { |r| match_rule(r, user) }
136
+ else
137
+ rules.all? { |r| match_rule(r, user) }
138
+ end
139
+ return false unless matched
140
+
141
+ salt = (entry_salt && !entry_salt.to_s.empty?) ? entry_salt : (entry["id"] || entry[:id] || fallback_salt)
142
+ bucket_hit(effective_pct(entry, now), pick_identifier(user, bucket_by), salt)
143
+ else
144
+ salt = (entry_salt && !entry_salt.to_s.empty?) ? entry_salt : fallback_salt
145
+ bucket_hit(effective_pct(entry, now), pick_identifier(user, bucket_by), salt)
146
+ end
147
+ end
148
+
73
149
  def self.eval_gate(gate, user)
74
150
  return false if enabled?(gate["killswitch"])
75
151
  return false unless enabled?(gate["enabled"])
76
152
 
153
+ # Modern gatekeepers ship an ordered `stack`; evaluate it top-to-bottom and
154
+ # pass on the first entry whose rules match AND whose bucket hits. This is
155
+ # the canonical model — the flat `rules`/`rolloutPct` below are a lossy
156
+ # approximation (a whitelist condition at 100% collapses to `rolloutPct: 0`
157
+ # once the public rollout is 0%, which the flat path would wrongly read as
158
+ # "never"). Mirrors @shipeasy/core evalGatekeeper — keep the two in sync.
159
+ stack = gate["stack"] || gate[:stack]
160
+ if stack.is_a?(Array) && !stack.empty?
161
+ now = (Time.now.to_f * 1000).to_i
162
+ gate_salt = gate["salt"] || gate[:salt]
163
+ stack.each do |entry|
164
+ return true if eval_stack_entry(entry, user, gate_salt, now)
165
+ end
166
+ return false
167
+ end
168
+
77
169
  (gate["rules"] || []).each do |rule|
78
170
  return false unless match_rule(rule, user)
79
171
  end
@@ -121,7 +121,15 @@ module Shipeasy
121
121
  # ---- Wire event construction ----
122
122
 
123
123
  # Build the type:"error" event accepted by POST /collect.
124
- 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)
125
133
  stack = nil
126
134
 
127
135
  if problem.is_a?(Violation)
@@ -131,7 +139,7 @@ module Shipeasy
131
139
  elsif problem.is_a?(Exception)
132
140
  error_type = problem.class.name || "Error"
133
141
  message = (problem.message.to_s.empty? ? error_type : problem.message)
134
- bt = problem.backtrace
142
+ bt = clean_backtrace(problem.backtrace, backtrace_cleaner)
135
143
  stack = bt.join("\n") if bt && !bt.empty?
136
144
  kind = "caught"
137
145
  else
@@ -158,6 +166,21 @@ module Shipeasy
158
166
  ev
159
167
  end
160
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
+
161
184
  # ---- Spam limiter (mirror SeeLimiter) ----
162
185
 
163
186
  # Per-process spam guard: identical events within 30s collapse to one
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "3.4.0"
3
+ VERSION = "3.5.1"
4
4
  end
5
5
  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.4.0
4
+ version: 3.5.1
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-14 00:00:00.000000000 Z
11
+ date: 2026-07-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rspec