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 +4 -4
- data/lib/shipeasy/sdk/eval.rb +38 -3
- data/lib/shipeasy/sdk/flags_client.rb +117 -3
- data/lib/shipeasy/sdk/openfeature.rb +204 -0
- data/lib/shipeasy/sdk/see.rb +284 -0
- data/lib/shipeasy/sdk/sticky_store.rb +39 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +50 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73c3a9cf2fd1a6fb0a223b9db5bcbe13cd828bfd96a4e901de545ff56d254ce7
|
|
4
|
+
data.tar.gz: '0489c3e16d16592b1301e888c82fa1d5947c21c08c74adbd9eacb6db86420138'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9f2a91c5482d5eb3baf71e744116b7295ad50b19cea9a96c2ce6b6faa01467a38ca4db91f449ae773de76144156eaeec14052d14d12f3c68a3dc521fa1c6813b
|
|
7
|
+
data.tar.gz: b8e2512bbc85e404fa4528c411700bff22e7eea40f20be2d6392bd7f59166eb65464cfe17760f8b7a2f56c0eb2c7ca15a42fbc579c0937f04ce61c402824a862
|
data/lib/shipeasy/sdk/eval.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
**(
|
|
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
|
data/lib/shipeasy/sdk/version.rb
CHANGED
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
|
+
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-
|
|
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
|