shipeasy-sdk 1.4.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: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
4
- data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
3
+ metadata.gz: 73c3a9cf2fd1a6fb0a223b9db5bcbe13cd828bfd96a4e901de545ff56d254ce7
4
+ data.tar.gz: '0489c3e16d16592b1301e888c82fa1d5947c21c08c74adbd9eacb6db86420138'
5
5
  SHA512:
6
- metadata.gz: cff2bbf433472f1e2be585ccbf5deb12d420e29130bb8f09961f2b277135a0c1726c1318a0f1b7b57fce511b114b6fcde982cc0735b884bb946de46016638c52
7
- data.tar.gz: 1ff69897f3b0501e6c4e41fa5eb386017fc4d714a0a5423871ec867b969bea2d1097fda01fb3902b1a57bc37a0490aaf433169e9d829f11739f3ac63cbcabb56
6
+ metadata.gz: 9f2a91c5482d5eb3baf71e744116b7295ad50b19cea9a96c2ce6b6faa01467a38ca4db91f449ae773de76144156eaeec14052d14d12f3c68a3dc521fa1c6813b
7
+ data.tar.gz: b8e2512bbc85e404fa4528c411700bff22e7eea40f20be2d6392bd7f59166eb65464cfe17760f8b7a2f56c0eb2c7ca15a42fbc579c0937f04ce61c402824a862
@@ -57,6 +57,19 @@ module Shipeasy
57
57
  end
58
58
  end
59
59
 
60
+ # Pick the bucketing identifier. When bucket_by is set and the user
61
+ # carries that attribute as a non-empty string (or any number, stringified),
62
+ # bucket on it — so a whole company/org lands on one variant. Otherwise fall
63
+ # back to user_id, then anonymous_id. Mirrors core's pickIdentifier.
64
+ def self.pick_identifier(user, bucket_by)
65
+ if bucket_by && !bucket_by.to_s.empty?
66
+ v = user[bucket_by] || user[bucket_by.to_sym]
67
+ return v if v.is_a?(String) && !v.empty?
68
+ return v.to_s if v.is_a?(Numeric)
69
+ end
70
+ user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
71
+ end
72
+
60
73
  def self.eval_gate(gate, user)
61
74
  return false if enabled?(gate["killswitch"])
62
75
  return false unless enabled?(gate["enabled"])
@@ -79,7 +92,14 @@ module Shipeasy
79
92
 
80
93
  ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
81
94
 
82
- def self.eval_experiment(exp, flags_blob, exps_blob, user)
95
+ # exp_name + sticky_store are optional so existing callers stay deterministic.
96
+ # When a sticky_store is passed, an enrolled unit whose stored salt prefix
97
+ # still matches skips the allocation gate (so a shrinking allocation keeps
98
+ # it in) and returns the stored group without re-running the pick. A fresh
99
+ # pick is persisted via store.set; a salt mismatch / missing stored group
100
+ # falls through to re-bucket + overwrite. Mirrors the TS reference
101
+ # (doc 20 §2). exp_name is the key under which the entry is stored.
102
+ def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
83
103
  not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
84
104
 
85
105
  return not_in unless exp && exp["status"] == "running"
@@ -90,7 +110,8 @@ module Shipeasy
90
110
  return not_in unless gate && eval_gate(gate, user)
91
111
  end
92
112
 
93
- uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
113
+ bucket_by = exp["bucketBy"] || exp[:bucketBy]
114
+ uid = pick_identifier(user, bucket_by)
94
115
  return not_in unless uid
95
116
 
96
117
  universe_name = exp["universe"]
@@ -103,14 +124,28 @@ module Shipeasy
103
124
 
104
125
  salt = exp["salt"]
105
126
  allocation_pct = exp["allocationPct"] || 0
127
+ groups = exp["groups"] || []
128
+ salt8 = (salt || "")[0, 8]
129
+
130
+ # Sticky short-circuit: an enrolled unit whose stored salt prefix still
131
+ # matches skips allocation and returns the stored group. If the stored
132
+ # group no longer exists, fall through to re-bucket + overwrite.
133
+ if sticky_store && exp_name
134
+ entry = (sticky_store.get(uid) || {})[exp_name]
135
+ if entry && entry["s"] == salt8
136
+ g = groups.find { |x| x["name"] == entry["g"] }
137
+ return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"]) if g
138
+ end
139
+ end
140
+
106
141
  return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
107
142
 
108
143
  group_hash = murmur3("#{salt}:group:#{uid}") % 10000
109
144
  cumulative = 0
110
- groups = exp["groups"] || []
111
145
  groups.each_with_index do |g, i|
112
146
  cumulative += g["weight"]
113
147
  if group_hash < cumulative || i == groups.length - 1
148
+ sticky_store.set(uid, exp_name, { "g" => g["name"], "s" => salt8 }) if sticky_store && exp_name
114
149
  return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
115
150
  end
116
151
  end
@@ -5,15 +5,29 @@ require "thread"
5
5
  require_relative "eval"
6
6
  require_relative "telemetry"
7
7
  require_relative "anon_id"
8
+ require_relative "sticky_store"
9
+ require_relative "see"
8
10
 
9
11
  module Shipeasy
10
12
  module SDK
11
13
  class FlagsClient
12
14
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
13
15
 
14
- def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false)
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)
15
17
  @api_key = api_key
16
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
22
+ # Attribute names usable for targeting but stripped from every outbound
23
+ # /collect payload (LD/Statsig privateAttributes). The server evaluates
24
+ # locally so private attrs never leave for evaluation; the only egress is
25
+ # track(), where the listed keys are dropped from the props bag.
26
+ @private_attributes = (private_attributes || []).map(&:to_s)
27
+ # Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
28
+ # Threaded into get_experiment so an enrolled unit locks to its first
29
+ # assigned variant. Built-in: InMemoryStickyStore.
30
+ @sticky_store = sticky_store
17
31
  # Test mode: no network, ever. init/init_once/track become no-ops and
18
32
  # evaluation answers come purely from local overrides. Built via the
19
33
  # FlagsClient.for_testing factory; see clear_overrides / override_*.
@@ -45,6 +59,13 @@ module Shipeasy
45
59
  # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
46
60
  # @mutex; see on_change / notify_change.
47
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)
48
69
  end
49
70
 
50
71
  # Build a no-network, immediately-usable client for tests. Telemetry is
@@ -228,7 +249,10 @@ module Shipeasy
228
249
  @telemetry.emit("experiment", name)
229
250
  flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
230
251
  exp = exps_blob&.dig("experiments", name)
231
- result = Eval.eval_experiment(exp, flags_blob, exps_blob, with_anon_id(user))
252
+ result = Eval.eval_experiment(
253
+ exp, flags_blob, exps_blob, with_anon_id(user),
254
+ exp_name: name.to_s, sticky_store: @sticky_store,
255
+ )
232
256
  result.params ||= default_params
233
257
 
234
258
  if result.in_experiment && decode
@@ -250,13 +274,15 @@ module Shipeasy
250
274
  def track(user_id, event_name, props = {})
251
275
  return if @test_mode
252
276
 
277
+ safe_props = strip_private(props)
278
+
253
279
  payload = JSON.generate({
254
280
  events: [{
255
281
  type: "metric",
256
282
  event_name: event_name,
257
283
  user_id: user_id.to_s,
258
284
  ts: (Time.now.to_f * 1000).to_i,
259
- **(props.empty? ? {} : { properties: props }),
285
+ **(safe_props.empty? ? {} : { properties: safe_props }),
260
286
  }],
261
287
  })
262
288
 
@@ -267,8 +293,96 @@ module Shipeasy
267
293
  end
268
294
  end
269
295
 
296
+ # Emit an exposure event for an experiment at the server-side decision
297
+ # point (parity with the browser's auto-exposure). The server is stateless
298
+ # and never auto-logs, so call this when you actually present the
299
+ # treatment. Re-evaluates the experiment for the user (a bare user_id
300
+ # string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
301
+ # exposure to /collect. No-op in test mode or when the user isn't enrolled.
302
+ def log_exposure(user_or_user_id, experiment_name)
303
+ return if @test_mode
304
+
305
+ user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
306
+ result = get_experiment(experiment_name, user, {})
307
+ return unless result.in_experiment
308
+
309
+ u = user.transform_keys(&:to_s)
310
+ payload = JSON.generate({
311
+ events: [{
312
+ type: "exposure",
313
+ experiment: experiment_name.to_s,
314
+ group: result.group,
315
+ user_id: (u["user_id"] || u["anonymous_id"]).to_s,
316
+ ts: (Time.now.to_f * 1000).to_i,
317
+ }],
318
+ })
319
+
320
+ Thread.new do
321
+ post("/collect", payload)
322
+ rescue => e
323
+ warn "[shipeasy] log_exposure failed: #{e.message}"
324
+ end
325
+ end
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
+
270
352
  private
271
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
+
379
+ # Drop caller-marked private attributes from an outbound props bag. Handles
380
+ # both string and symbol keys against the stringified private list.
381
+ def strip_private(props)
382
+ return props if props.nil? || props.empty? || @private_attributes.empty?
383
+ props.reject { |k, _| @private_attributes.include?(k.to_s) }
384
+ end
385
+
272
386
  # Load a parsed snapshot into the local blobs and mark the client ready,
273
387
  # without any network. Used by from_snapshot / from_file on a test_mode
274
388
  # client so the real evaluator runs against captured data.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OpenFeature provider for Shipeasy (server paradigm).
4
+ #
5
+ # Lets apps standardized on the CNCF OpenFeature API plug Shipeasy in as the
6
+ # backing provider. This file is intentionally NOT required by the main
7
+ # `shipeasy-sdk` entrypoint — `openfeature-sdk` is an optional development
8
+ # dependency, so the provider is loaded lazily and the gem is required from
9
+ # inside this file. Require it explicitly when you want the provider:
10
+ #
11
+ # require "open_feature/sdk"
12
+ # require "shipeasy/sdk/openfeature"
13
+ #
14
+ # client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
15
+ # client.init
16
+ #
17
+ # OpenFeature::SDK.configure do |config|
18
+ # config.set_provider(Shipeasy::OpenFeature::Provider.new(client))
19
+ # end
20
+ #
21
+ # of = OpenFeature::SDK.build_client
22
+ # on = of.fetch_boolean_value(flag_key: "new_checkout", default_value: false,
23
+ # evaluation_context: OpenFeature::SDK::EvaluationContext.new(targeting_key: "u1"))
24
+ #
25
+ # Pure adapter over `FlagsClient` — no change to evaluation. Boolean values map
26
+ # onto gates (`get_flag_detail`); string/number/integer/float/object map onto
27
+ # dynamic configs (`get_config`).
28
+
29
+ # `openfeature-sdk` (module `OpenFeature::SDK::Provider`) is an optional dep.
30
+ # Require it lazily so the main SDK never pulls it in; surface a clear error if
31
+ # the consumer forgot to add it.
32
+ begin
33
+ require "open_feature/sdk"
34
+ rescue LoadError => e
35
+ raise LoadError, "shipeasy/sdk/openfeature requires the `openfeature-sdk` gem " \
36
+ "(module OpenFeature::SDK::Provider). Add it to your Gemfile: " \
37
+ "gem \"openfeature-sdk\". (#{e.message})"
38
+ end
39
+
40
+ require_relative "flags_client"
41
+
42
+ module Shipeasy
43
+ module OpenFeature
44
+ # Shipeasy OpenFeature provider (server paradigm). Wraps a
45
+ # `Shipeasy::SDK::FlagsClient`; evaluation is local against the cached blob,
46
+ # so resolution is effectively synchronous.
47
+ class Provider
48
+ OF = ::OpenFeature::SDK::Provider
49
+
50
+ # Shipeasy `FlagDetail#reason` → [OpenFeature reason, optional error_code].
51
+ # Per the cross-SDK contract (doc 20):
52
+ # RULE_MATCH → TARGETING_MATCH
53
+ # DEFAULT → DEFAULT
54
+ # OFF → DISABLED
55
+ # OVERRIDE → STATIC
56
+ # FLAG_NOT_FOUND → ERROR (error_code FLAG_NOT_FOUND)
57
+ # CLIENT_NOT_READY → ERROR (error_code PROVIDER_NOT_READY)
58
+ REASON_MAP = {
59
+ Shipeasy::SDK::FlagsClient::REASON_RULE_MATCH => [OF::Reason::TARGETING_MATCH, nil],
60
+ Shipeasy::SDK::FlagsClient::REASON_DEFAULT => [OF::Reason::DEFAULT, nil],
61
+ Shipeasy::SDK::FlagsClient::REASON_OFF => [OF::Reason::DISABLED, nil],
62
+ Shipeasy::SDK::FlagsClient::REASON_OVERRIDE => [OF::Reason::STATIC, nil],
63
+ Shipeasy::SDK::FlagsClient::REASON_FLAG_NOT_FOUND => [OF::Reason::ERROR, OF::ErrorCode::FLAG_NOT_FOUND],
64
+ Shipeasy::SDK::FlagsClient::REASON_CLIENT_NOT_READY => [OF::Reason::ERROR, OF::ErrorCode::PROVIDER_NOT_READY],
65
+ }.freeze
66
+
67
+ attr_reader :metadata
68
+
69
+ def initialize(client)
70
+ @client = client
71
+ @metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
72
+ end
73
+
74
+ # OpenFeature lifecycle (optional but supported): fetch the blob once and
75
+ # tear down the poll thread on shutdown.
76
+ def init(_evaluation_context = nil)
77
+ @client.init_once
78
+ end
79
+
80
+ def shutdown
81
+ @client.destroy
82
+ end
83
+
84
+ # --- Boolean → gate ------------------------------------------------------
85
+
86
+ def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil)
87
+ user = to_user(evaluation_context)
88
+ detail = @client.get_flag_detail(flag_key, user)
89
+ of_reason, error_code = REASON_MAP.fetch(detail.reason, [OF::Reason::UNKNOWN, nil])
90
+
91
+ if error_code
92
+ OF::ResolutionDetails.new(value: default_value, reason: of_reason, error_code: error_code)
93
+ else
94
+ OF::ResolutionDetails.new(value: detail.value, reason: of_reason)
95
+ end
96
+ rescue => e
97
+ OF::ResolutionDetails.new(
98
+ value: default_value, reason: OF::Reason::ERROR,
99
+ error_code: OF::ErrorCode::GENERAL, error_message: e.message,
100
+ )
101
+ end
102
+
103
+ # --- String / number / integer / float / object → dynamic config --------
104
+
105
+ def fetch_string_value(flag_key:, default_value:, evaluation_context: nil)
106
+ resolve_config(flag_key, default_value) { |v| v.is_a?(String) }
107
+ end
108
+
109
+ def fetch_number_value(flag_key:, default_value:, evaluation_context: nil)
110
+ resolve_config(flag_key, default_value) { |v| numeric?(v) }
111
+ end
112
+
113
+ def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil)
114
+ resolve_config(flag_key, default_value) { |v| v.is_a?(Integer) }
115
+ end
116
+
117
+ def fetch_float_value(flag_key:, default_value:, evaluation_context: nil)
118
+ resolve_config(flag_key, default_value) { |v| numeric?(v) }
119
+ end
120
+
121
+ def fetch_object_value(flag_key:, default_value:, evaluation_context: nil)
122
+ resolve_config(flag_key, default_value) { |v| v.is_a?(Hash) || v.is_a?(Array) }
123
+ end
124
+
125
+ # OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
126
+ def track(tracking_event_name, evaluation_context: nil, details: {})
127
+ ctx = normalize_context(evaluation_context)
128
+ user_id = ctx["targeting_key"] || ctx["user_id"]
129
+ return if user_id.nil? || user_id.to_s.empty?
130
+
131
+ props = details.is_a?(Hash) ? details : {}
132
+ @client.track(user_id, tracking_event_name, props)
133
+ end
134
+
135
+ private
136
+
137
+ # A sentinel distinct from any legitimate config value so we can tell an
138
+ # absent key (→ DEFAULT) from a present-but-nil value.
139
+ ABSENT = Object.new
140
+ private_constant :ABSENT
141
+
142
+ # Resolve a dynamic config and type-check it. Absent key → DEFAULT;
143
+ # present but failing the type predicate → TYPE_MISMATCH; otherwise
144
+ # TARGETING_MATCH with the value. Both return the default for the value.
145
+ def resolve_config(flag_key, default_value)
146
+ raw = @client.get_config(flag_key, nil, default: ABSENT)
147
+
148
+ if raw.equal?(ABSENT)
149
+ return OF::ResolutionDetails.new(value: default_value, reason: OF::Reason::DEFAULT)
150
+ end
151
+
152
+ unless yield(raw)
153
+ return OF::ResolutionDetails.new(
154
+ value: default_value, reason: OF::Reason::ERROR,
155
+ error_code: OF::ErrorCode::TYPE_MISMATCH,
156
+ error_message: "config value #{raw.inspect} does not match the requested type",
157
+ )
158
+ end
159
+
160
+ OF::ResolutionDetails.new(value: raw, reason: OF::Reason::TARGETING_MATCH)
161
+ rescue => e
162
+ OF::ResolutionDetails.new(
163
+ value: default_value, reason: OF::Reason::ERROR,
164
+ error_code: OF::ErrorCode::GENERAL, error_message: e.message,
165
+ )
166
+ end
167
+
168
+ def numeric?(value)
169
+ # Booleans are Integers' cousins in some langs but not Ruby; exclude
170
+ # them explicitly so `true` never satisfies a number/float request.
171
+ return false if value == true || value == false
172
+
173
+ value.is_a?(Numeric)
174
+ end
175
+
176
+ # Build a Shipeasy user hash from an OpenFeature EvaluationContext:
177
+ # `targeting_key` → `user_id`; every other field carried through verbatim
178
+ # for targeting. Accepts a real EvaluationContext, a plain Hash, or nil.
179
+ def to_user(evaluation_context)
180
+ ctx = normalize_context(evaluation_context)
181
+ targeting_key = ctx["targeting_key"]
182
+ rest = ctx.reject { |k, _| k == "targeting_key" }
183
+ user = rest
184
+ if targeting_key.is_a?(String) && !targeting_key.empty?
185
+ user = user.merge("user_id" => targeting_key)
186
+ end
187
+ user
188
+ end
189
+
190
+ # Coerce any of {EvaluationContext, Hash, nil} into a string-keyed Hash.
191
+ def normalize_context(evaluation_context)
192
+ return {} if evaluation_context.nil?
193
+
194
+ if evaluation_context.respond_to?(:fields)
195
+ evaluation_context.fields.transform_keys(&:to_s)
196
+ elsif evaluation_context.is_a?(Hash)
197
+ evaluation_context.transform_keys(&:to_s)
198
+ else
199
+ {}
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -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
@@ -0,0 +1,39 @@
1
+ require "thread"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Pluggable sticky-bucketing store for the server (doc 20 §2). Duck-typed:
6
+ # any object responding to the two methods below works.
7
+ #
8
+ # get(unit) -> { exp_name => { "g" => group, "s" => salt8 } } or nil
9
+ # set(unit, exp_name, entry) # entry = { "g" => group, "s" => salt8 }
10
+ #
11
+ # Keyed by the bucketing unit (pick_identifier-resolved id). When threaded
12
+ # into experiment eval, an enrolled unit locks to its first-assigned variant
13
+ # — changing allocation % or weights won't re-bucket it; changing the
14
+ # experiment salt is the reshuffle lever. Absent ⇒ deterministic behavior.
15
+ class InMemoryStickyStore
16
+ # Optionally seed with { unit => { exp => { "g"=>.., "s"=>.. } } }.
17
+ def initialize(seed = nil)
18
+ @mutex = Mutex.new
19
+ @data = {}
20
+ if seed
21
+ seed.each { |unit, exps| @data[unit.to_s] = exps.dup }
22
+ end
23
+ end
24
+
25
+ # Return this unit's per-experiment assignments, or nil if none.
26
+ def get(unit)
27
+ @mutex.synchronize { @data[unit.to_s] }
28
+ end
29
+
30
+ # Persist one assignment for (unit, exp).
31
+ def set(unit, exp, entry)
32
+ @mutex.synchronize do
33
+ (@data[unit.to_s] ||= {})[exp] = entry
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.4.0"
3
+ VERSION = "1.6.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -2,6 +2,7 @@ require_relative "shipeasy/sdk/version"
2
2
  require_relative "shipeasy/config"
3
3
  require_relative "shipeasy/sdk/murmur3"
4
4
  require_relative "shipeasy/sdk/eval"
5
+ require_relative "shipeasy/sdk/sticky_store"
5
6
  require_relative "shipeasy/sdk/flags_client"
6
7
  require_relative "shipeasy/sdk/anon_id"
7
8
  require_relative "shipeasy/sdk/rack_middleware"
@@ -24,5 +25,54 @@ module Shipeasy
24
25
  def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
25
26
  FlagsClient.new(api_key: api_key, base_url: base_url)
26
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
27
77
  end
28
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.4.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
@@ -73,8 +73,11 @@ files:
73
73
  - lib/shipeasy/sdk/eval.rb
74
74
  - lib/shipeasy/sdk/flags_client.rb
75
75
  - lib/shipeasy/sdk/murmur3.rb
76
+ - lib/shipeasy/sdk/openfeature.rb
76
77
  - lib/shipeasy/sdk/rack_middleware.rb
77
78
  - lib/shipeasy/sdk/railtie.rb
79
+ - lib/shipeasy/sdk/see.rb
80
+ - lib/shipeasy/sdk/sticky_store.rb
78
81
  - lib/shipeasy/sdk/telemetry.rb
79
82
  - lib/shipeasy/sdk/version.rb
80
83
  homepage: https://github.com/shipeasy-ai/sdk-ruby