shipeasy-sdk 2.3.1 → 3.0.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/README.md +11 -8
- data/docs/skill/SKILL.md +11 -6
- data/lib/shipeasy/client.rb +47 -7
- data/lib/shipeasy/config.rb +22 -4
- data/lib/shipeasy/engine.rb +267 -122
- data/lib/shipeasy/logging.rb +79 -0
- data/lib/shipeasy/sdk/eval.rb +115 -7
- data/lib/shipeasy/sdk/internal_report.rb +154 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +3 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a34364fd54857fe0b04997063d9d2bbb6296e281a3db8b7389efd84c5f1b4a1
|
|
4
|
+
data.tar.gz: bb1c68285633d2f1a5709286197b9a0aacaf7c78d950908e414f3fde64f44144
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f4f9a1149dabefce47743afb04bec2e3f3e6dceac88c9e1a48a2cdb187b87bce0c5c9853cc68afe3f64f7a521fe8dcb4d5017c679ea362f19f8b598d71b3dbcb
|
|
7
|
+
data.tar.gz: b82a76824d4d0a90cc481453c5036a53cdb1adb30e74630ac843c6d6582fa294391e440b0d29b044eebf25071eb264bbab3a74491fac3939a815064ca1fd0f92
|
data/README.md
CHANGED
|
@@ -59,8 +59,8 @@ flags = Shipeasy::Client.new(current_user)
|
|
|
59
59
|
|
|
60
60
|
flags.get_flag("new_checkout") # NO user arg — bound at construction
|
|
61
61
|
flags.get_config("button_color")
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
assignment = flags.universe("checkout").assign # <=1 experiment; auto-logs exposure
|
|
63
|
+
assignment.get("label", "Buy") # variant ?? universe default ?? fallback
|
|
64
64
|
flags.track("purchase", { revenue: 49 }) # on conversion
|
|
65
65
|
flags.get_killswitch("payments")
|
|
66
66
|
```
|
|
@@ -78,7 +78,7 @@ Constructing `Shipeasy::Client.new(user)` before `Shipeasy.configure` raises
|
|
|
78
78
|
| [Feature flags](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/flags.md) | `get_flag`, `get_flag_detail`, defaults. |
|
|
79
79
|
| [Dynamic configs](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configs.md) | `get_config`, typed decode, defaults. |
|
|
80
80
|
| [Kill switches](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/killswitches.md) | `get_killswitch`, named switches. |
|
|
81
|
-
| [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `
|
|
81
|
+
| [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `universe(name).assign`, `Assignment#get`, `track`. |
|
|
82
82
|
| [Internationalization](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/i18n.md) | Rails view helpers + the SSR loader tag. |
|
|
83
83
|
| [Error reporting](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/error-reporting.md) | `see()` structured error reporting. |
|
|
84
84
|
| [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) | `configure_for_testing` / `configure_for_offline`, overrides. |
|
|
@@ -109,12 +109,15 @@ client = Shipeasy::Client.new({ "user_id" => "u_123" })
|
|
|
109
109
|
client.get_flag("new_checkout") # => true
|
|
110
110
|
client.get_config("billing_copy") # => { "title" => "Welcome" }
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
# An experiment override surfaces through universe(name).assign once the loaded
|
|
113
|
+
# blob maps that experiment to its universe (an offline snapshot does this; a
|
|
114
|
+
# bare configure_for_testing has no blob). Read via the universe:
|
|
115
|
+
assignment = client.universe("checkout").assign
|
|
116
|
+
assignment.enrolled? # => true
|
|
117
|
+
assignment.group # => "treatment"
|
|
118
|
+
assignment.get("color") # => "green"
|
|
116
119
|
|
|
117
|
-
# track /
|
|
120
|
+
# track / assign exposures are no-ops in test mode — safe to call, send nothing
|
|
118
121
|
client.track("purchase", { amount: 49 })
|
|
119
122
|
```
|
|
120
123
|
|
data/docs/skill/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: shipeasy-ruby
|
|
3
|
-
description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/
|
|
3
|
+
description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/universe(name).assign/get_killswitch, track, testing, OpenFeature.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Shipeasy Ruby SDK
|
|
@@ -46,8 +46,10 @@ the default (one-shot fetch, no thread) is serverless-friendly.
|
|
|
46
46
|
|
|
47
47
|
## Evaluate (bound `Client.new(user)` — NO user arg)
|
|
48
48
|
|
|
49
|
-
Bind the user once per request, then call without re-passing it —
|
|
50
|
-
|
|
49
|
+
Bind the user once per request, then call without re-passing it — experiments
|
|
50
|
+
are read by **universe** (a mutual-exclusion pool: the unit lands in <=1
|
|
51
|
+
experiment), and `track` is on the same bound client, so experiments are
|
|
52
|
+
end-to-end here:
|
|
51
53
|
|
|
52
54
|
```ruby
|
|
53
55
|
flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
|
|
@@ -55,10 +57,13 @@ flags = Shipeasy::Client.new(current_user) # runs the attributes transform onc
|
|
|
55
57
|
flags.get_flag("new_checkout") # bool; default: only when unresolved
|
|
56
58
|
flags.get_config("button_color", default: "blue")
|
|
57
59
|
flags.get_killswitch("payments") # true = killed; optional switch_key
|
|
58
|
-
result = flags.get_experiment("checkout_cta", { label: "Buy now" })
|
|
59
|
-
# result.in_experiment / result.group / result.params
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
# Ask the UNIVERSE, not the experiment. Returns an Assignment (never raises):
|
|
62
|
+
# .name / .group → nil when not enrolled · .enrolled? → group non-nil
|
|
63
|
+
# .get(field, fallback = nil) → variant override ?? universe default ?? fallback
|
|
64
|
+
assignment = flags.universe("checkout").assign # auto-logs one deduped exposure when enrolled
|
|
65
|
+
render_cta(assignment.get("label", "Buy now"))
|
|
66
|
+
|
|
62
67
|
flags.track("purchase", { revenue: 49 }) # conversion / metric event
|
|
63
68
|
```
|
|
64
69
|
|
data/lib/shipeasy/client.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Shipeasy
|
|
|
4
4
|
#
|
|
5
5
|
# flags = Shipeasy::Client.new(current_user)
|
|
6
6
|
# flags.get_flag("new_checkout") # NO user arg — bound at construction
|
|
7
|
-
# flags.
|
|
7
|
+
# flags.universe("checkout").assign # NO user arg — bound at construction
|
|
8
8
|
#
|
|
9
9
|
# It is cheap: it delegates every evaluation to the single global engine built
|
|
10
10
|
# by `Shipeasy.configure { … }`. It does NOT open its own HTTP connection,
|
|
@@ -34,35 +34,75 @@ module Shipeasy
|
|
|
34
34
|
@attributes = engine.bind_attributes(mapped)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# NOTE on fail-safe reads: the engine's runtime methods already never raise
|
|
38
|
+
# (each is wrapped in Engine#safe_run). The extra defensive rescue here is a
|
|
39
|
+
# belt-and-braces guard so even an unexpected failure BEFORE the engine call
|
|
40
|
+
# (e.g. an @attributes deref) still returns the documented safe default
|
|
41
|
+
# rather than propagating into product code.
|
|
42
|
+
|
|
37
43
|
def get_flag(name, default: false)
|
|
38
44
|
@engine.get_flag(name, @attributes, default: default)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Shipeasy::Logging.error "[shipeasy] Client#get_flag('#{name}') failed — returning default: #{e.message}"
|
|
47
|
+
default
|
|
39
48
|
end
|
|
40
49
|
|
|
41
50
|
def get_flag_detail(name)
|
|
42
51
|
@engine.get_flag_detail(name, @attributes)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Shipeasy::Logging.error "[shipeasy] Client#get_flag_detail('#{name}') failed — returning safe default: #{e.message}"
|
|
54
|
+
Shipeasy::Engine::FlagDetail.new(value: false, reason: Shipeasy::Engine::REASON_CLIENT_NOT_READY)
|
|
43
55
|
end
|
|
44
56
|
|
|
45
57
|
# Configs are not user-scoped, but exposed here for one-stop ergonomics.
|
|
46
58
|
def get_config(name, decode = nil, default: nil)
|
|
47
59
|
@engine.get_config(name, decode, default: default)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Shipeasy::Logging.error "[shipeasy] Client#get_config('#{name}') failed — returning default: #{e.message}"
|
|
62
|
+
default
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Assign the bound user within a universe: `client.universe("checkout").assign`.
|
|
66
|
+
# A universe is a mutual-exclusion pool — the unit lands in at most one
|
|
67
|
+
# experiment. Returns a reusable handle whose `assign` takes NO user arg (the
|
|
68
|
+
# user is bound at construction) and forwards the bound attributes to the
|
|
69
|
+
# engine. `assign` auto-logs a single deduped exposure when enrolled and
|
|
70
|
+
# returns an Eval::Assignment (never raises).
|
|
71
|
+
def universe(name)
|
|
72
|
+
BoundUniverseHandle.new(@engine, name, @attributes)
|
|
48
73
|
end
|
|
49
74
|
|
|
50
|
-
|
|
51
|
-
|
|
75
|
+
# Returned by Client#universe. Binds the universe name AND the client's
|
|
76
|
+
# already-resolved attributes, so `assign` needs no user argument.
|
|
77
|
+
class BoundUniverseHandle
|
|
78
|
+
def initialize(engine, name, attributes)
|
|
79
|
+
@engine = engine
|
|
80
|
+
@name = name
|
|
81
|
+
@attributes = attributes
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def assign
|
|
85
|
+
@engine.assign_universe(@name, @attributes)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
Shipeasy::Logging.error "[shipeasy] Client#universe('#{@name}').assign failed — returning not-enrolled: #{e.message}"
|
|
88
|
+
Shipeasy::SDK::Eval::Assignment.new(nil, nil, {})
|
|
89
|
+
end
|
|
52
90
|
end
|
|
53
91
|
|
|
54
92
|
# Killswitches are not user-scoped; forwarded straight to the engine.
|
|
55
93
|
def get_killswitch(name, switch_key = nil)
|
|
56
94
|
@engine.get_killswitch(name, switch_key)
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Shipeasy::Logging.error "[shipeasy] Client#get_killswitch('#{name}') failed — returning false: #{e.message}"
|
|
97
|
+
false
|
|
57
98
|
end
|
|
58
99
|
|
|
59
100
|
def track(event_name, props = {})
|
|
60
101
|
id = @attributes["user_id"] || @attributes["anonymous_id"]
|
|
61
102
|
@engine.track(id, event_name, props)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
@engine.log_exposure(@attributes, experiment_name)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Shipeasy::Logging.error "[shipeasy] Client#track('#{event_name}') failed: #{e.message}"
|
|
105
|
+
nil
|
|
66
106
|
end
|
|
67
107
|
end
|
|
68
108
|
|
data/lib/shipeasy/config.rb
CHANGED
|
@@ -30,8 +30,22 @@ module Shipeasy
|
|
|
30
30
|
# - private_attributes: attribute keys stripped from every outbound event
|
|
31
31
|
# before it leaves the process (they still drive targeting locally).
|
|
32
32
|
# - sticky_store: pin a user's experiment group across re-buckets.
|
|
33
|
+
# - disable_internal_error_reporting (default false): opt out of the
|
|
34
|
+
# SDK's self-monitoring channel. When the SDK's last-resort guard
|
|
35
|
+
# swallows one of its OWN internal errors it normally ships a structured
|
|
36
|
+
# see event to Shipeasy's own project (NOT yours) so the SDK team can
|
|
37
|
+
# track SDK bugs; set true to disable that entirely.
|
|
33
38
|
attr_accessor :env, :disable_telemetry, :telemetry_url,
|
|
34
|
-
:private_attributes, :sticky_store
|
|
39
|
+
:private_attributes, :sticky_store,
|
|
40
|
+
:disable_internal_error_reporting
|
|
41
|
+
|
|
42
|
+
# SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
|
|
43
|
+
# One of :silent, :error, :warn (default), :info, :debug (strings accepted
|
|
44
|
+
# and downcased; unknown falls back to :warn). Controls the stderr output of
|
|
45
|
+
# every caught-error diagnostic the SDK emits — the public runtime reads
|
|
46
|
+
# (get_flag/get_config/…) never raise, so this only tunes how loud they are
|
|
47
|
+
# about a recovered failure. Threaded into the global Engine by configure.
|
|
48
|
+
attr_accessor :log_level
|
|
35
49
|
|
|
36
50
|
# Fetch lifecycle for the global engine `configure` builds:
|
|
37
51
|
# - init (default true): fire a one-shot fetch fire-and-forget so the first
|
|
@@ -61,15 +75,17 @@ module Shipeasy
|
|
|
61
75
|
:manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
|
|
62
76
|
|
|
63
77
|
def initialize
|
|
64
|
-
@base_url = "https://
|
|
78
|
+
@base_url = "https://api.shipeasy.ai"
|
|
65
79
|
@attributes = nil
|
|
66
80
|
@init = true
|
|
67
81
|
@poll = false
|
|
68
82
|
@env = "prod"
|
|
69
83
|
@disable_telemetry = false
|
|
84
|
+
@disable_internal_error_reporting = false
|
|
70
85
|
@telemetry_url = nil
|
|
71
86
|
@private_attributes = nil
|
|
72
87
|
@sticky_store = nil
|
|
88
|
+
@log_level = :warn
|
|
73
89
|
|
|
74
90
|
@profile = "default"
|
|
75
91
|
@default_chunk = "index"
|
|
@@ -147,6 +163,8 @@ module Shipeasy
|
|
|
147
163
|
telemetry_url: cfg.telemetry_url,
|
|
148
164
|
private_attributes: cfg.private_attributes,
|
|
149
165
|
sticky_store: cfg.sticky_store,
|
|
166
|
+
log_level: cfg.log_level,
|
|
167
|
+
disable_internal_error_reporting: cfg.disable_internal_error_reporting,
|
|
150
168
|
)
|
|
151
169
|
@engine = engine
|
|
152
170
|
# Capture +engine+ in the closure (not the @engine ivar, which a concurrent
|
|
@@ -155,13 +173,13 @@ module Shipeasy
|
|
|
155
173
|
Thread.new do
|
|
156
174
|
engine.init # initial fetch + background poll thread
|
|
157
175
|
rescue => e
|
|
158
|
-
|
|
176
|
+
Shipeasy::Logging.error "[shipeasy] configure(poll) background poll failed: #{e.message}"
|
|
159
177
|
end
|
|
160
178
|
elsif cfg.init
|
|
161
179
|
Thread.new do
|
|
162
180
|
engine.init_once
|
|
163
181
|
rescue => e
|
|
164
|
-
|
|
182
|
+
Shipeasy::Logging.error "[shipeasy] configure() one-shot fetch failed: #{e.message}"
|
|
165
183
|
end
|
|
166
184
|
end
|
|
167
185
|
engine
|
data/lib/shipeasy/engine.rb
CHANGED
|
@@ -3,11 +3,13 @@ require "uri"
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "thread"
|
|
5
5
|
require "cgi"
|
|
6
|
+
require_relative "logging"
|
|
6
7
|
require_relative "sdk/eval"
|
|
7
8
|
require_relative "sdk/telemetry"
|
|
8
9
|
require_relative "sdk/anon_id"
|
|
9
10
|
require_relative "sdk/sticky_store"
|
|
10
11
|
require_relative "sdk/see"
|
|
12
|
+
require_relative "sdk/internal_report"
|
|
11
13
|
|
|
12
14
|
module Shipeasy
|
|
13
15
|
# The heavyweight engine: owns the api key, HTTP transport, the blob cache,
|
|
@@ -28,12 +30,17 @@ module Shipeasy
|
|
|
28
30
|
AnonId = Shipeasy::SDK::AnonId
|
|
29
31
|
See = Shipeasy::SDK::See
|
|
30
32
|
|
|
31
|
-
DEFAULT_BASE_URL = "https://
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.shipeasy.ai"
|
|
32
34
|
# CDN origin serving the static loader scripts (/sdk/bootstrap.js,
|
|
33
35
|
# /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
|
|
34
36
|
DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
|
|
35
37
|
|
|
36
|
-
def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil)
|
|
38
|
+
def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false, private_attributes: nil, sticky_store: nil, log_level: nil, disable_internal_error_reporting: false)
|
|
39
|
+
# SDK-wide diagnostic verbosity. Set the leveled logger from the passed
|
|
40
|
+
# level (default :warn; unknown falls back to :warn). The logger is
|
|
41
|
+
# module-scoped, so the last-built engine wins — mirrors the TS SDK,
|
|
42
|
+
# where the last configure() sets the level.
|
|
43
|
+
Shipeasy::Logging.set_level(log_level || Shipeasy::Logging::DEFAULT_LEVEL)
|
|
37
44
|
@api_key = api_key
|
|
38
45
|
@base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
|
|
39
46
|
# Read-env tag. Used by telemetry below and stamped onto see() error
|
|
@@ -45,7 +52,7 @@ module Shipeasy
|
|
|
45
52
|
# track(), where the listed keys are dropped from the props bag.
|
|
46
53
|
@private_attributes = (private_attributes || []).map(&:to_s)
|
|
47
54
|
# Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
|
|
48
|
-
# Threaded into
|
|
55
|
+
# Threaded into experiment eval so an enrolled unit locks to its first
|
|
49
56
|
# assigned variant. Built-in: InMemoryStickyStore.
|
|
50
57
|
@sticky_store = sticky_store
|
|
51
58
|
# Test mode: no network, ever. init/init_once/track become no-ops and
|
|
@@ -82,6 +89,22 @@ module Shipeasy
|
|
|
82
89
|
# see() structured error reporting. Per-process spam guard, bound here so
|
|
83
90
|
# repeated reports of the same issue collapse to one send. See see.rb.
|
|
84
91
|
@see_limiter = See::Limiter.new
|
|
92
|
+
# Auto-exposure dedup set: assign() logs a single exposure per
|
|
93
|
+
# (unit, experiment, group) so repeated assigns in one process don't spam
|
|
94
|
+
# /collect. Bounded — cleared when it grows past ~5000 keys.
|
|
95
|
+
@exposure_seen = {}
|
|
96
|
+
# Self-monitoring channel: when safe_run swallows one of the SDK's OWN
|
|
97
|
+
# internal errors, it also ships a see event to Shipeasy's own project
|
|
98
|
+
# (a baked-in destination, distinct from the consumer's see() path) so
|
|
99
|
+
# the SDK team can track SDK bugs across every app. Fire-and-forget,
|
|
100
|
+
# never raises. On by default; forced off in test mode (no network) and
|
|
101
|
+
# opt-out-able via disable_internal_error_reporting. Module-scoped, so
|
|
102
|
+
# the last-built engine wins — mirrors set_level / the TS reference.
|
|
103
|
+
Shipeasy::SDK::InternalReport.set_context(
|
|
104
|
+
side: "server",
|
|
105
|
+
sdk_version: Shipeasy::SDK::VERSION,
|
|
106
|
+
enabled: !test_mode && !disable_internal_error_reporting,
|
|
107
|
+
)
|
|
85
108
|
# Register as the default client backing the module-level Shipeasy::SDK
|
|
86
109
|
# .see/.see_violation funcs (last constructed wins — the server-SDK
|
|
87
110
|
# analog of TS's shipeasy({key}) configure call).
|
|
@@ -205,6 +228,12 @@ module Shipeasy
|
|
|
205
228
|
# Evaluate a flag and return why. Telemetry ("gate" beacon) is emitted
|
|
206
229
|
# exactly once here (steps 2–5), never on the OVERRIDE short-circuit.
|
|
207
230
|
def get_flag_detail(name, user)
|
|
231
|
+
safe_run("get_flag_detail('#{name}')", FlagDetail.new(value: false, reason: REASON_CLIENT_NOT_READY)) do
|
|
232
|
+
get_flag_detail_inner(name, user)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def get_flag_detail_inner(name, user)
|
|
208
237
|
key = name.to_s
|
|
209
238
|
|
|
210
239
|
# 1. Override short-circuits before any telemetry (mirrors get_config).
|
|
@@ -233,66 +262,106 @@ module Shipeasy
|
|
|
233
262
|
end
|
|
234
263
|
|
|
235
264
|
def get_flag(name, user, default: false)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
265
|
+
safe_run("get_flag('#{name}')", default) do
|
|
266
|
+
detail = get_flag_detail(name, user)
|
|
267
|
+
if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
|
|
268
|
+
default
|
|
269
|
+
else
|
|
270
|
+
detail.value
|
|
271
|
+
end
|
|
241
272
|
end
|
|
242
273
|
end
|
|
243
274
|
|
|
244
275
|
def get_config(name, decode = nil, default: nil)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
276
|
+
safe_run("get_config('#{name}')", default) do
|
|
277
|
+
key = name.to_s
|
|
278
|
+
has_override, override = @mutex.synchronize do
|
|
279
|
+
[@config_overrides.key?(key), @config_overrides[key]]
|
|
280
|
+
end
|
|
281
|
+
if has_override
|
|
282
|
+
begin
|
|
283
|
+
next(decode ? decode.call(override) : override)
|
|
284
|
+
rescue => e
|
|
285
|
+
Shipeasy::Logging.warn "[shipeasy] get_config('#{name}') decode failed: #{e.message}"
|
|
286
|
+
next default
|
|
287
|
+
end
|
|
288
|
+
end
|
|
252
289
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
290
|
+
@telemetry.emit("config", name)
|
|
291
|
+
entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
|
|
292
|
+
next default unless entry
|
|
293
|
+
value = entry["value"]
|
|
294
|
+
begin
|
|
295
|
+
decode ? decode.call(value) : value
|
|
296
|
+
rescue => e
|
|
297
|
+
Shipeasy::Logging.warn "[shipeasy] get_config('#{name}') decode failed: #{e.message}"
|
|
298
|
+
default
|
|
299
|
+
end
|
|
300
|
+
end
|
|
258
301
|
end
|
|
259
302
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
303
|
+
# Assign +user+ within +universe_name+. A universe is a mutual-exclusion
|
|
304
|
+
# pool, so a unit lands in AT MOST ONE experiment; the returned
|
|
305
|
+
# Eval::Assignment exposes the variant + resolved params and auto-logs a
|
|
306
|
+
# single exposure when enrolled. An un-enrolled unit still resolves get()
|
|
307
|
+
# to the universe defaults. Never raises. This is the sole experiment read
|
|
308
|
+
# path — there is no get_experiment (a caller asks a universe, not an
|
|
309
|
+
# experiment). Internal: the public surface is universe(name).assign(user).
|
|
310
|
+
def assign_universe(universe_name, user)
|
|
311
|
+
empty = Eval::Assignment.new(nil, nil, {})
|
|
312
|
+
safe_run("assign_universe('#{universe_name}')", empty) do
|
|
313
|
+
@telemetry.emit("experiment", universe_name)
|
|
314
|
+
u = with_anon_id(user)
|
|
315
|
+
flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
|
|
316
|
+
|
|
317
|
+
universe = exps_blob&.dig("universes", universe_name.to_s)
|
|
318
|
+
param_defaults = Eval.param_defaults_from_schema(
|
|
319
|
+
universe && (universe["param_schema"] || universe[:param_schema])
|
|
270
320
|
)
|
|
321
|
+
not_enrolled = Eval::Assignment.new(nil, nil, param_defaults || {})
|
|
322
|
+
next not_enrolled unless exps_blob
|
|
323
|
+
|
|
324
|
+
# Candidate running experiments in this universe. Deterministic order:
|
|
325
|
+
# pool-slice offset asc (slices are disjoint so <=1 matches under
|
|
326
|
+
# pooling), then name. A universe-held-out or unallocated unit falls
|
|
327
|
+
# through to the defaults-only handle.
|
|
328
|
+
candidates = (exps_blob["experiments"] || {}).select do |_name, exp|
|
|
329
|
+
exp["universe"] == universe_name.to_s && exp["status"] == "running"
|
|
330
|
+
end.sort_by { |name, exp| [(exp["poolOffsetBp"] || 0), name] }
|
|
331
|
+
|
|
332
|
+
landed = nil
|
|
333
|
+
candidates.each do |name, exp|
|
|
334
|
+
result = eval_experiment(name, exp, u, flags_blob, exps_blob)
|
|
335
|
+
next unless result.in_experiment
|
|
336
|
+
post_exposure(u, name, result.group)
|
|
337
|
+
landed = Eval::Assignment.new(name, result.group, result.params || {})
|
|
338
|
+
break
|
|
339
|
+
# not enrolled: try the next candidate — under pooling only one slice
|
|
340
|
+
# can match, so the loop lands on the winner (or falls through).
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
landed || not_enrolled
|
|
271
344
|
end
|
|
345
|
+
end
|
|
272
346
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
)
|
|
280
|
-
result.params ||= default_params
|
|
347
|
+
# A reusable handle bound to one universe. +assign(user)+ picks the <=1
|
|
348
|
+
# experiment the unit is pooled into and auto-logs a single exposure. See
|
|
349
|
+
# assign_universe.
|
|
350
|
+
def universe(name)
|
|
351
|
+
UniverseHandle.new(self, name)
|
|
352
|
+
end
|
|
281
353
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
)
|
|
289
|
-
rescue => e
|
|
290
|
-
warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
|
|
291
|
-
return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
|
|
292
|
-
end
|
|
354
|
+
# Returned by Engine#universe. Binds a universe name so callers can reuse
|
|
355
|
+
# the handle: `engine.universe("checkout").assign(user)`.
|
|
356
|
+
class UniverseHandle
|
|
357
|
+
def initialize(engine, name)
|
|
358
|
+
@engine = engine
|
|
359
|
+
@name = name
|
|
293
360
|
end
|
|
294
361
|
|
|
295
|
-
|
|
362
|
+
def assign(user)
|
|
363
|
+
@engine.assign_universe(@name, user)
|
|
364
|
+
end
|
|
296
365
|
end
|
|
297
366
|
|
|
298
367
|
# Public hook for the bound Shipeasy::Client: normalise an attribute hash
|
|
@@ -309,16 +378,18 @@ module Shipeasy
|
|
|
309
378
|
# value (so an unconfigured key behaves exactly like the no-key call).
|
|
310
379
|
# Unknown killswitches return false. Not user-scoped.
|
|
311
380
|
def get_killswitch(name, switch_key = nil)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
381
|
+
safe_run("get_killswitch('#{name}')", false) do
|
|
382
|
+
@telemetry.emit("ks", name)
|
|
383
|
+
ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
|
|
384
|
+
next false unless ks
|
|
385
|
+
unless switch_key.nil?
|
|
386
|
+
switches = ks["switches"] || {}
|
|
387
|
+
key = switch_key.to_s
|
|
388
|
+
next Eval.enabled?(switches[key]) if switches.key?(key)
|
|
389
|
+
# key not configured → fall through to the top-level value
|
|
390
|
+
end
|
|
391
|
+
Eval.enabled?(ks["killed"])
|
|
320
392
|
end
|
|
321
|
-
Eval.enabled?(ks["killed"])
|
|
322
393
|
end
|
|
323
394
|
|
|
324
395
|
# Batch-evaluate every loaded gate, config and experiment for +user+ into
|
|
@@ -329,9 +400,8 @@ module Shipeasy
|
|
|
329
400
|
# for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
|
|
330
401
|
def evaluate(user)
|
|
331
402
|
u = with_anon_id(user)
|
|
332
|
-
flags_blob, exps_blob, flag_ov, config_ov
|
|
333
|
-
[@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup
|
|
334
|
-
@exp_overrides.dup, @sticky_store]
|
|
403
|
+
flags_blob, exps_blob, flag_ov, config_ov = @mutex.synchronize do
|
|
404
|
+
[@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup]
|
|
335
405
|
end
|
|
336
406
|
|
|
337
407
|
flags = {}
|
|
@@ -344,18 +414,30 @@ module Shipeasy
|
|
|
344
414
|
configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
|
|
345
415
|
end
|
|
346
416
|
|
|
417
|
+
# Per-experiment result carries the universe name; a top-level universes
|
|
418
|
+
# map exposes each universe's param defaults so the client can resolve
|
|
419
|
+
# universe(name).get() to a default even when the unit is not enrolled.
|
|
347
420
|
experiments = {}
|
|
421
|
+
universes = {}
|
|
348
422
|
(exps_blob&.dig("experiments") || {}).each do |name, exp|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
423
|
+
uni_name = exp["universe"]
|
|
424
|
+
unless universes.key?(uni_name)
|
|
425
|
+
uni = exps_blob&.dig("universes", uni_name)
|
|
426
|
+
universes[uni_name] = {
|
|
427
|
+
"defaults" => Eval.param_defaults_from_schema(uni && (uni["param_schema"] || uni[:param_schema])) || {},
|
|
428
|
+
}
|
|
353
429
|
end
|
|
354
|
-
r =
|
|
355
|
-
experiments[name] = {
|
|
430
|
+
r = eval_experiment(name, exp, u, flags_blob, exps_blob, emit_telemetry: false)
|
|
431
|
+
experiments[name] = {
|
|
432
|
+
"inExperiment" => r.in_experiment,
|
|
433
|
+
"group" => r.in_experiment ? r.group : "control",
|
|
434
|
+
"params" => r.in_experiment ? (r.params || {}) : {},
|
|
435
|
+
"universe" => uni_name,
|
|
436
|
+
}
|
|
356
437
|
end
|
|
357
438
|
|
|
358
|
-
{ "flags" => flags, "configs" => configs, "experiments" => experiments,
|
|
439
|
+
{ "flags" => flags, "configs" => configs, "experiments" => experiments,
|
|
440
|
+
"killswitches" => {}, "universes" => universes }
|
|
359
441
|
end
|
|
360
442
|
|
|
361
443
|
# Return the cross-platform SSR bootstrap <script> tag for a request:
|
|
@@ -387,55 +469,27 @@ module Shipeasy
|
|
|
387
469
|
end
|
|
388
470
|
|
|
389
471
|
def track(user_id, event_name, props = {})
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
# Emit an exposure event for an experiment at the server-side decision
|
|
412
|
-
# point (parity with the browser's auto-exposure). The server is stateless
|
|
413
|
-
# and never auto-logs, so call this when you actually present the
|
|
414
|
-
# treatment. Re-evaluates the experiment for the user (a bare user_id
|
|
415
|
-
# string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
|
|
416
|
-
# exposure to /collect. No-op in test mode or when the user isn't enrolled.
|
|
417
|
-
def log_exposure(user_or_user_id, experiment_name)
|
|
418
|
-
return if @test_mode
|
|
419
|
-
|
|
420
|
-
user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
|
|
421
|
-
result = get_experiment(experiment_name, user, {})
|
|
422
|
-
return unless result.in_experiment
|
|
423
|
-
|
|
424
|
-
u = user.transform_keys(&:to_s)
|
|
425
|
-
payload = JSON.generate({
|
|
426
|
-
events: [{
|
|
427
|
-
type: "exposure",
|
|
428
|
-
experiment: experiment_name.to_s,
|
|
429
|
-
group: result.group,
|
|
430
|
-
user_id: (u["user_id"] || u["anonymous_id"]).to_s,
|
|
431
|
-
ts: (Time.now.to_f * 1000).to_i,
|
|
432
|
-
}],
|
|
433
|
-
})
|
|
434
|
-
|
|
435
|
-
Thread.new do
|
|
436
|
-
post("/collect", payload)
|
|
437
|
-
rescue => e
|
|
438
|
-
warn "[shipeasy] log_exposure failed: #{e.message}"
|
|
472
|
+
safe_run("track('#{event_name}')", nil) do
|
|
473
|
+
next if @test_mode
|
|
474
|
+
|
|
475
|
+
safe_props = strip_private(props)
|
|
476
|
+
|
|
477
|
+
payload = JSON.generate({
|
|
478
|
+
events: [{
|
|
479
|
+
type: "metric",
|
|
480
|
+
event_name: event_name,
|
|
481
|
+
user_id: user_id.to_s,
|
|
482
|
+
ts: (Time.now.to_f * 1000).to_i,
|
|
483
|
+
**(safe_props.empty? ? {} : { properties: safe_props }),
|
|
484
|
+
}],
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
Thread.new do
|
|
488
|
+
post("/collect", payload)
|
|
489
|
+
rescue => e
|
|
490
|
+
Shipeasy::Logging.warn "[shipeasy] track failed: #{e.message}"
|
|
491
|
+
end
|
|
492
|
+
nil
|
|
439
493
|
end
|
|
440
494
|
end
|
|
441
495
|
|
|
@@ -466,6 +520,97 @@ module Shipeasy
|
|
|
466
520
|
|
|
467
521
|
private
|
|
468
522
|
|
|
523
|
+
# Last-resort guard that makes a public RUNTIME method (get_flag /
|
|
524
|
+
# get_config / assign_universe / get_killswitch / track) unable to raise
|
|
525
|
+
# into product code, even if an internal invariant is
|
|
526
|
+
# violated. Runs the block; on any StandardError it logs at :error and
|
|
527
|
+
# returns +fallback+ (the method's documented safe default). +label+ names
|
|
528
|
+
# the method for the log line.
|
|
529
|
+
def safe_run(label, fallback)
|
|
530
|
+
yield
|
|
531
|
+
rescue StandardError => e
|
|
532
|
+
Shipeasy::Logging.error "[shipeasy] #{label} failed — returning safe default: #{e.message}"
|
|
533
|
+
# A caught error here is by definition "on our end" — an internal SDK
|
|
534
|
+
# failure, not the caller's — so in addition to logging locally it is
|
|
535
|
+
# reported to Shipeasy's own project via the self-monitoring channel
|
|
536
|
+
# (fire-and-forget, never raises). The label's stable stem (e.g.
|
|
537
|
+
# "get_flag" from "get_flag('new_checkout')") is the issue subject, so
|
|
538
|
+
# occurrences of the same bug dedupe regardless of the resource name.
|
|
539
|
+
Shipeasy::SDK::InternalReport.report(internal_subject(label), e)
|
|
540
|
+
fallback
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# The stable subject for an internal-error report: strip the variable
|
|
544
|
+
# "('resource')" argument off a safe_run label so the fingerprint carries
|
|
545
|
+
# no variable data and identical bugs dedupe into one issue.
|
|
546
|
+
def internal_subject(label)
|
|
547
|
+
label.to_s.sub(/\(.*\)\z/, "")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Evaluate one experiment by name for +user+ — override -> full classify
|
|
551
|
+
# pipeline (targeting -> universe holdout -> holdout gate -> sticky ->
|
|
552
|
+
# allocation -> group), merging the universe defaults under the assigned
|
|
553
|
+
# variant. Returns an Eval::ExperimentResult. Reused by assign_universe and
|
|
554
|
+
# the SSR evaluate() bootstrap (keyed by experiment name). Emits the
|
|
555
|
+
# per-experiment telemetry beacon exactly once (never on the override
|
|
556
|
+
# short-circuit), unless +emit_telemetry+ is false (the batch evaluate()
|
|
557
|
+
# path is not a per-experiment exposure). +user+ is expected pre-normalised
|
|
558
|
+
# (with_anon_id).
|
|
559
|
+
def eval_experiment(name, exp, user, flags_blob, exps_blob, emit_telemetry: true)
|
|
560
|
+
key = name.to_s
|
|
561
|
+
override = @mutex.synchronize { @exp_overrides[key] }
|
|
562
|
+
if override
|
|
563
|
+
universe = exps_blob&.dig("universes", exp && exp["universe"])
|
|
564
|
+
param_defaults = Eval.param_defaults_from_schema(
|
|
565
|
+
universe && (universe["param_schema"] || universe[:param_schema])
|
|
566
|
+
)
|
|
567
|
+
return Eval::ExperimentResult.new(
|
|
568
|
+
in_experiment: true,
|
|
569
|
+
group: override[:group],
|
|
570
|
+
params: Eval.merge_params(param_defaults, override[:params]),
|
|
571
|
+
)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
@telemetry.emit("experiment", name) if emit_telemetry
|
|
575
|
+
Eval.eval_experiment(
|
|
576
|
+
exp, flags_blob, exps_blob, user,
|
|
577
|
+
exp_name: key, sticky_store: @sticky_store,
|
|
578
|
+
)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# POST a single exposure for an enrolled (user, experiment, group). Deduped
|
|
582
|
+
# per process (bounded set) so repeated assign() calls in one server don't
|
|
583
|
+
# spam /collect. Fire-and-forget; no-op in test mode. This is how
|
|
584
|
+
# assign_universe auto-logs — the browser's auto-exposure parity for SSR.
|
|
585
|
+
def post_exposure(user, experiment, group)
|
|
586
|
+
return if @test_mode
|
|
587
|
+
u = user.transform_keys(&:to_s)
|
|
588
|
+
uid = u["user_id"] || u["anonymous_id"]
|
|
589
|
+
dedup_key = "#{uid}:#{experiment}:#{group}"
|
|
590
|
+
@mutex.synchronize do
|
|
591
|
+
return if @exposure_seen.key?(dedup_key)
|
|
592
|
+
@exposure_seen.clear if @exposure_seen.size > 5000
|
|
593
|
+
@exposure_seen[dedup_key] = true
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
payload = JSON.generate({
|
|
597
|
+
events: [{
|
|
598
|
+
type: "exposure",
|
|
599
|
+
experiment: experiment.to_s,
|
|
600
|
+
group: group,
|
|
601
|
+
user_id: uid.to_s,
|
|
602
|
+
ts: (Time.now.to_f * 1000).to_i,
|
|
603
|
+
}],
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
Thread.new do
|
|
607
|
+
post("/collect", payload)
|
|
608
|
+
rescue => e
|
|
609
|
+
Shipeasy::Logging.warn "[shipeasy] exposure send failed: #{e.message}"
|
|
610
|
+
end
|
|
611
|
+
nil
|
|
612
|
+
end
|
|
613
|
+
|
|
469
614
|
# Build the wire event and fire-and-forget POST it to /collect. No-op in
|
|
470
615
|
# test mode (mirrors track). Spam-guarded. Never raises into caller code.
|
|
471
616
|
def dispatch_see(built)
|
|
@@ -485,10 +630,10 @@ module Shipeasy
|
|
|
485
630
|
Thread.new do
|
|
486
631
|
post("/collect", payload)
|
|
487
632
|
rescue => e
|
|
488
|
-
warn "[shipeasy] see() send failed: #{e.message}"
|
|
633
|
+
Shipeasy::Logging.warn "[shipeasy] see() send failed: #{e.message}"
|
|
489
634
|
end
|
|
490
635
|
rescue => e
|
|
491
|
-
|
|
636
|
+
Shipeasy::Logging.error "[shipeasy] see() failed: #{e.message}"
|
|
492
637
|
end
|
|
493
638
|
|
|
494
639
|
# Drop caller-marked private attributes from an outbound props bag. Handles
|
|
@@ -519,7 +664,7 @@ module Shipeasy
|
|
|
519
664
|
begin
|
|
520
665
|
listener.call
|
|
521
666
|
rescue => e
|
|
522
|
-
warn "[shipeasy] on_change listener raised: #{e.message}"
|
|
667
|
+
Shipeasy::Logging.warn "[shipeasy] on_change listener raised: #{e.message}"
|
|
523
668
|
end
|
|
524
669
|
end
|
|
525
670
|
end
|
|
@@ -557,7 +702,7 @@ module Shipeasy
|
|
|
557
702
|
begin
|
|
558
703
|
fetch_all
|
|
559
704
|
rescue => e
|
|
560
|
-
|
|
705
|
+
Shipeasy::Logging.error "[shipeasy] background poll failed: #{e.message}"
|
|
561
706
|
end
|
|
562
707
|
end
|
|
563
708
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Leveled logger shared by the whole gem.
|
|
2
|
+
#
|
|
3
|
+
# Every diagnostic the SDK emits from a *caught* error goes through here, so a
|
|
4
|
+
# single `log_level` config option (default :warn) controls the SDK's stderr
|
|
5
|
+
# output. The contract for the SDK's public RUNTIME methods (get_flag,
|
|
6
|
+
# get_config, universe(...).assign, get_killswitch, track, see, …) is
|
|
7
|
+
# that they NEVER raise into product code — so logging itself is best-effort
|
|
8
|
+
# too: a broken/throwing $stderr can never take down a flag read.
|
|
9
|
+
#
|
|
10
|
+
# Level ordering (a message at level L is emitted iff the configured level is at
|
|
11
|
+
# least as verbose as L):
|
|
12
|
+
# silent < error < warn < info < debug
|
|
13
|
+
# :warn (the default) therefore emits `error` + `warn` and suppresses the
|
|
14
|
+
# informational `info` / `debug` chatter.
|
|
15
|
+
|
|
16
|
+
module Shipeasy
|
|
17
|
+
module Logging
|
|
18
|
+
# All accepted levels, in increasing verbosity.
|
|
19
|
+
LEVELS = %i[silent error warn info debug].freeze
|
|
20
|
+
|
|
21
|
+
RANK = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }.freeze
|
|
22
|
+
|
|
23
|
+
DEFAULT_LEVEL = :warn
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Set the SDK-wide log level. Accepts a symbol or string (case-insensitive);
|
|
27
|
+
# an unrecognised value falls back to the default :warn so a bad config value
|
|
28
|
+
# can never silence a genuine error unexpectedly.
|
|
29
|
+
def set_level(level)
|
|
30
|
+
@level = normalize(level)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The current SDK-wide log level (defaults to :warn until set).
|
|
34
|
+
def level
|
|
35
|
+
@level || DEFAULT_LEVEL
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Normalise any input to a known level symbol; unknown → DEFAULT_LEVEL.
|
|
39
|
+
def normalize(level)
|
|
40
|
+
sym =
|
|
41
|
+
case level
|
|
42
|
+
when Symbol then level.to_s.downcase.to_sym
|
|
43
|
+
when String then level.downcase.to_sym
|
|
44
|
+
else nil
|
|
45
|
+
end
|
|
46
|
+
RANK.key?(sym) ? sym : DEFAULT_LEVEL
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error(msg)
|
|
50
|
+
emit(:error, msg)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def warn(msg)
|
|
54
|
+
emit(:warn, msg)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def info(msg)
|
|
58
|
+
emit(:info, msg)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def debug(msg)
|
|
62
|
+
emit(:debug, msg)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Gate on the configured level, then write to $stderr exactly as the old
|
|
68
|
+
# bare `warn "[shipeasy] …"` calls did (Kernel#warn → $stderr). Never
|
|
69
|
+
# raises: a broken stream is swallowed.
|
|
70
|
+
def emit(msg_level, msg)
|
|
71
|
+
return if RANK[level] < RANK[msg_level]
|
|
72
|
+
|
|
73
|
+
Kernel.warn(msg)
|
|
74
|
+
rescue StandardError
|
|
75
|
+
# logging must never raise into product code
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/shipeasy/sdk/eval.rb
CHANGED
|
@@ -92,6 +92,26 @@ module Shipeasy
|
|
|
92
92
|
|
|
93
93
|
ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
|
|
94
94
|
|
|
95
|
+
# Flatten a universe param schema (`[{ "name", "type", "default" }, ...]`)
|
|
96
|
+
# to a plain `name => default` map — the defaults `assign()` layers under a
|
|
97
|
+
# variant's override map. Returns nil for a null/empty schema so the merge
|
|
98
|
+
# short-circuits. Mirrors the TS reference / @shipeasy/core.
|
|
99
|
+
def self.param_defaults_from_schema(schema)
|
|
100
|
+
return nil if schema.nil? || schema.empty?
|
|
101
|
+
schema.each_with_object({}) do |p, h|
|
|
102
|
+
name = p["name"] || p[:name]
|
|
103
|
+
h[name] = p.key?("default") ? p["default"] : p[:default]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `universeDefaults ⊕ variantOverride` — a variant inherits every universe
|
|
108
|
+
# default it doesn't explicitly override. A nil defaults map short-circuits
|
|
109
|
+
# to the variant params (dup'd) alone.
|
|
110
|
+
def self.merge_params(param_defaults, group_params)
|
|
111
|
+
gp = group_params || {}
|
|
112
|
+
param_defaults ? param_defaults.merge(gp) : gp.dup
|
|
113
|
+
end
|
|
114
|
+
|
|
95
115
|
# exp_name + sticky_store are optional so existing callers stay deterministic.
|
|
96
116
|
# When a sticky_store is passed, an enrolled unit whose stored salt prefix
|
|
97
117
|
# still matches skips the allocation gate (so a shrinking allocation keeps
|
|
@@ -99,8 +119,23 @@ module Shipeasy
|
|
|
99
119
|
# pick is persisted via store.set; a salt mismatch / missing stored group
|
|
100
120
|
# falls through to re-bucket + overwrite. Mirrors the TS reference
|
|
101
121
|
# (doc 20 §2). exp_name is the key under which the entry is stored.
|
|
122
|
+
#
|
|
123
|
+
# Pooling / holdout-gate / reserved-headroom / universe param-default merge
|
|
124
|
+
# (doc 20 §B) are layered in here so both the low-level parity path and the
|
|
125
|
+
# universe assign() path share ONE classify. The added steps are all guarded
|
|
126
|
+
# by presence checks, so a legacy blob (no hashVersion/pool/reserved) behaves
|
|
127
|
+
# exactly as before.
|
|
102
128
|
def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
|
|
129
|
+
universe_name = exp && exp["universe"]
|
|
130
|
+
universe = exps_blob&.dig("universes", universe_name)
|
|
131
|
+
param_defaults = param_defaults_from_schema(universe && (universe["param_schema"] || universe[:param_schema]))
|
|
132
|
+
|
|
103
133
|
not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
|
|
134
|
+
as_group = lambda do |g|
|
|
135
|
+
ExperimentResult.new(
|
|
136
|
+
in_experiment: true, group: g["name"], params: merge_params(param_defaults, g["params"]),
|
|
137
|
+
)
|
|
138
|
+
end
|
|
104
139
|
|
|
105
140
|
return not_in unless exp && exp["status"] == "running"
|
|
106
141
|
|
|
@@ -114,12 +149,22 @@ module Shipeasy
|
|
|
114
149
|
uid = pick_identifier(user, bucket_by)
|
|
115
150
|
return not_in unless uid
|
|
116
151
|
|
|
117
|
-
|
|
118
|
-
|
|
152
|
+
# One segment in the universe's shared [0, 10000) hash space. The holdout
|
|
153
|
+
# carve-out AND every experiment's pool slice are disjoint ranges of THIS
|
|
154
|
+
# segment — that's what makes "held out / taken / free" a real partition.
|
|
155
|
+
universe_seg = murmur3("#{universe_name}:#{uid}") % 10000
|
|
156
|
+
|
|
119
157
|
holdout = universe&.dig("holdout_range")
|
|
120
158
|
if holdout
|
|
121
|
-
|
|
122
|
-
|
|
159
|
+
return not_in if universe_seg >= holdout[0] && universe_seg <= holdout[1]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Holdout gate: a passing gate holds the unit out of every experiment in
|
|
163
|
+
# the universe (mirrors the universe carve-out, but gate-driven).
|
|
164
|
+
holdout_gate = exp["holdoutGate"]
|
|
165
|
+
if holdout_gate && !holdout_gate.to_s.empty?
|
|
166
|
+
gate = flags_blob&.dig("gates", holdout_gate)
|
|
167
|
+
return not_in if gate && eval_gate(gate, user)
|
|
123
168
|
end
|
|
124
169
|
|
|
125
170
|
salt = exp["salt"]
|
|
@@ -134,24 +179,87 @@ module Shipeasy
|
|
|
134
179
|
entry = (sticky_store.get(uid) || {})[exp_name]
|
|
135
180
|
if entry && entry["s"] == salt8
|
|
136
181
|
g = groups.find { |x| x["name"] == entry["g"] }
|
|
137
|
-
return
|
|
182
|
+
return as_group.call(g) if g
|
|
138
183
|
end
|
|
139
184
|
end
|
|
140
185
|
|
|
141
|
-
|
|
186
|
+
# Allocation. Pooled (hashVersion >= 2 with a slice) gives real mutual
|
|
187
|
+
# exclusion: the unit's universe segment must fall in the claimed range.
|
|
188
|
+
# Legacy falls back to an independent per-experiment salt so siblings
|
|
189
|
+
# overlap freely (the existing parity path).
|
|
190
|
+
hash_version = exp["hashVersion"] || exp[:hashVersion] || 1
|
|
191
|
+
pool_offset = exp["poolOffsetBp"] || exp[:poolOffsetBp]
|
|
192
|
+
pool_size = exp["poolSizeBp"] || exp[:poolSizeBp]
|
|
193
|
+
pooled = hash_version >= 2 && !pool_offset.nil? && !pool_size.nil? && pool_size > 0
|
|
194
|
+
if pooled
|
|
195
|
+
lo = pool_offset
|
|
196
|
+
hi = pool_offset + pool_size
|
|
197
|
+
return not_in if universe_seg < lo || universe_seg >= hi
|
|
198
|
+
else
|
|
199
|
+
return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
|
|
200
|
+
end
|
|
142
201
|
|
|
202
|
+
# Group split over [0, usable) where usable = 10000 - reserved; a unit in
|
|
203
|
+
# the reserved tail is left unassigned so an appended variant can absorb it.
|
|
204
|
+
reserved = (exp["reservedHeadroomBp"] || exp[:reservedHeadroomBp] || 0)
|
|
205
|
+
reserved = 0 if reserved < 0
|
|
206
|
+
reserved = 10000 if reserved > 10000
|
|
207
|
+
usable = 10000 - reserved
|
|
143
208
|
group_hash = murmur3("#{salt}:group:#{uid}") % 10000
|
|
209
|
+
return not_in if group_hash >= usable
|
|
210
|
+
|
|
144
211
|
cumulative = 0
|
|
145
212
|
groups.each_with_index do |g, i|
|
|
146
213
|
cumulative += g["weight"]
|
|
147
214
|
if group_hash < cumulative || i == groups.length - 1
|
|
148
215
|
sticky_store.set(uid, exp_name, { "g" => g["name"], "s" => salt8 }) if sticky_store && exp_name
|
|
149
|
-
return
|
|
216
|
+
return as_group.call(g)
|
|
150
217
|
end
|
|
151
218
|
end
|
|
152
219
|
|
|
153
220
|
not_in
|
|
154
221
|
end
|
|
222
|
+
|
|
223
|
+
# The result of `universe(name).assign(user)` — a unit's standing in a
|
|
224
|
+
# universe (a mutual-exclusion pool, so it lands in at most one experiment).
|
|
225
|
+
# Never raises: an un-enrolled unit still resolves `get` to the universe
|
|
226
|
+
# defaults (or the caller's fallback). Reading is side-effect free — the
|
|
227
|
+
# single exposure is logged once by assign() when the unit is enrolled.
|
|
228
|
+
class Assignment
|
|
229
|
+
# The experiment the unit landed in, or nil when not enrolled.
|
|
230
|
+
attr_reader :name
|
|
231
|
+
# The assigned variant/group name, or nil when not enrolled.
|
|
232
|
+
attr_reader :group
|
|
233
|
+
|
|
234
|
+
# +params+ is already merged (universeDefaults ⊕ variantOverride) when
|
|
235
|
+
# enrolled; defaults-only (or {}) when not.
|
|
236
|
+
def initialize(name, group, params)
|
|
237
|
+
@name = name
|
|
238
|
+
@group = group
|
|
239
|
+
@params = params || {}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# True iff the unit is enrolled in an experiment in this universe.
|
|
243
|
+
def enrolled?
|
|
244
|
+
!@group.nil?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Read a resolved param: the assigned variant's override, else the
|
|
248
|
+
# universe default, else +fallback+. Works even when not enrolled (the
|
|
249
|
+
# variant layer is absent, so you get universeDefault ?? fallback).
|
|
250
|
+
# Looks up both string and symbol keys.
|
|
251
|
+
def get(field, fallback = nil)
|
|
252
|
+
if @params.key?(field)
|
|
253
|
+
@params[field]
|
|
254
|
+
elsif field.respond_to?(:to_s) && @params.key?(field.to_s)
|
|
255
|
+
@params[field.to_s]
|
|
256
|
+
elsif field.respond_to?(:to_sym) && @params.key?(field.to_sym)
|
|
257
|
+
@params[field.to_sym]
|
|
258
|
+
else
|
|
259
|
+
fallback
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
155
263
|
end
|
|
156
264
|
end
|
|
157
265
|
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Internal self-monitoring channel — SDK bugs that are "on our end".
|
|
2
|
+
#
|
|
3
|
+
# When the SDK swallows one of its OWN internal errors (the `Engine#safe_run`
|
|
4
|
+
# last-resort guard in engine.rb, which keeps a get_flag/get_config/… from
|
|
5
|
+
# raising into product code even when an internal invariant is violated), it
|
|
6
|
+
# ALSO ships a structured see event here — to Shipeasy's OWN project, NOT the
|
|
7
|
+
# consumer's — so the SDK team can track SDK-internal failures across every app
|
|
8
|
+
# the SDK runs in.
|
|
9
|
+
#
|
|
10
|
+
# This is deliberately distinct from the customer-facing `see()` path
|
|
11
|
+
# (Shipeasy::SDK.see / Engine#see), which authenticates with the consumer's key
|
|
12
|
+
# and lands in the consumer's dashboard. Internal errors must never pollute a
|
|
13
|
+
# customer's Errors tab, and the SDK team must see them centrally — so this
|
|
14
|
+
# channel has its own baked-in destination + credential.
|
|
15
|
+
#
|
|
16
|
+
# Guarantees (identical to telemetry/see): fire-and-forget, never blocks, never
|
|
17
|
+
# raises into product code, deduped/rate-limited. A failed send is swallowed
|
|
18
|
+
# silently — it must never log (that would risk recursion through safe_run).
|
|
19
|
+
|
|
20
|
+
require "net/http"
|
|
21
|
+
require "uri"
|
|
22
|
+
require "json"
|
|
23
|
+
require "thread"
|
|
24
|
+
require_relative "see"
|
|
25
|
+
require_relative "version"
|
|
26
|
+
|
|
27
|
+
module Shipeasy
|
|
28
|
+
module SDK
|
|
29
|
+
module InternalReport
|
|
30
|
+
# ---- Baked-in destination ----
|
|
31
|
+
#
|
|
32
|
+
# The main Shipeasy project (`.shipeasy` project_id
|
|
33
|
+
# e976b15e-3ccc-44d3-821d-87f06d5a0e43). The credential is a PUBLIC client
|
|
34
|
+
# key — the same class of credential already embedded verbatim in every
|
|
35
|
+
# browser bundle that ships the client SDK, and mirroring how the CLI bakes
|
|
36
|
+
# Shipeasy's own public key for setup-bug self-reporting — so baking it into
|
|
37
|
+
# the published gem is safe. `/collect` treats it as a write-only ingest
|
|
38
|
+
# key; it grants no read access. The canonical ingest host is
|
|
39
|
+
# api.shipeasy.ai (the SDK default base_url), which routes /collect to the
|
|
40
|
+
# edge worker.
|
|
41
|
+
INGEST_URL = "https://api.shipeasy.ai/collect".freeze
|
|
42
|
+
|
|
43
|
+
# Sentinel used until the real key is minted + baked. While INGEST_KEY is
|
|
44
|
+
# still the placeholder the channel stays fully inert (see .report), so a
|
|
45
|
+
# gem that ships before the key is provisioned never fires doomed requests.
|
|
46
|
+
# Mint the key with:
|
|
47
|
+
# shipeasy keys create --type client --env prod \
|
|
48
|
+
# --name "SDK internal error self-reporting" --scopes events:write
|
|
49
|
+
# then replace the INGEST_KEY assignment below with the returned value.
|
|
50
|
+
PLACEHOLDER_KEY = "sdk_client_REPLACE_WITH_SHIPEASY_INTERNAL_ERROR_KEY".freeze
|
|
51
|
+
|
|
52
|
+
# The baked-in ingest credential. Swap the placeholder for the real minted
|
|
53
|
+
# key here (this is the ONLY line to change when the key is provisioned).
|
|
54
|
+
INGEST_KEY = "sdk_client_00bd4608a03e4084922978f9522614d5"
|
|
55
|
+
|
|
56
|
+
# Stable consequence. The `label` (the safe_run operation name, e.g.
|
|
57
|
+
# "flags.get") is the subject; the outcome is fixed. Both are constant per
|
|
58
|
+
# operation — no variable data — so occurrences of the same internal bug
|
|
59
|
+
# fold into one issue on our dashboard (fingerprint = error_type +
|
|
60
|
+
# normalized message + top stack + subject|outcome). `sdk` marks which
|
|
61
|
+
# language SDK reported it.
|
|
62
|
+
OUTCOME = "returned a safe default".freeze
|
|
63
|
+
SDK_ID = "ruby".freeze
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# True once a real key has been baked in (not the placeholder sentinel).
|
|
67
|
+
def key_configured?(key = @ingest_key || INGEST_KEY)
|
|
68
|
+
!key.nil? && !key.empty? && key != PLACEHOLDER_KEY
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Wire the self-monitoring channel. Called from Engine#initialize with the
|
|
72
|
+
# bundle's side + version. `enabled` defaults on; it is forced off in test
|
|
73
|
+
# mode (no network) and when the caller opts out via
|
|
74
|
+
# disable_internal_error_reporting.
|
|
75
|
+
def set_context(side:, sdk_version:, enabled: true)
|
|
76
|
+
@side = side
|
|
77
|
+
@sdk_version = sdk_version
|
|
78
|
+
@enabled = enabled != false
|
|
79
|
+
@limiter ||= See::Limiter.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Report an SDK-internal error to Shipeasy's own project. Called from
|
|
83
|
+
# Engine#safe_run's rescue. `label` is the swallowed operation (e.g.
|
|
84
|
+
# "flags.get") and becomes the stable issue subject. Never raises.
|
|
85
|
+
def report(label, err)
|
|
86
|
+
return unless @enabled
|
|
87
|
+
key = @ingest_key || INGEST_KEY
|
|
88
|
+
return unless key_configured?(key)
|
|
89
|
+
|
|
90
|
+
ev = See.build_event(
|
|
91
|
+
err,
|
|
92
|
+
label.to_s,
|
|
93
|
+
OUTCOME,
|
|
94
|
+
{ "sdk" => SDK_ID },
|
|
95
|
+
sdk_version: @sdk_version || Shipeasy::SDK::VERSION,
|
|
96
|
+
env: nil,
|
|
97
|
+
)
|
|
98
|
+
# Internal reports carry only the SDK-side context — never a consumer's
|
|
99
|
+
# env or url — so a customer's config can't leak into our project.
|
|
100
|
+
ev["side"] = @side || "server"
|
|
101
|
+
|
|
102
|
+
limiter = (@limiter ||= See::Limiter.new)
|
|
103
|
+
return unless limiter.should_send?(ev)
|
|
104
|
+
|
|
105
|
+
send_event(key, JSON.generate({ events: [ev] }))
|
|
106
|
+
rescue StandardError
|
|
107
|
+
# Self-reporting must never raise into product code.
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# Fire-and-forget POST to the baked-in ingest on a background thread.
|
|
114
|
+
# Isolated so specs can intercept it without real network. A failed send
|
|
115
|
+
# is swallowed silently — it must never log (that would risk recursion
|
|
116
|
+
# through safe_run).
|
|
117
|
+
def send_event(key, body)
|
|
118
|
+
Thread.new do
|
|
119
|
+
begin
|
|
120
|
+
uri = URI.parse(INGEST_URL)
|
|
121
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
122
|
+
http.use_ssl = (uri.scheme == "https")
|
|
123
|
+
http.open_timeout = 2
|
|
124
|
+
http.read_timeout = 2
|
|
125
|
+
http.post(uri.request_uri, body, { "X-SDK-Key" => key, "Content-Type" => "text/plain" })
|
|
126
|
+
rescue StandardError
|
|
127
|
+
# self-reporting must never surface a network error
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- Test seams ----
|
|
133
|
+
|
|
134
|
+
public
|
|
135
|
+
|
|
136
|
+
# Reset module state (context + rate limiter + key override) so a spec
|
|
137
|
+
# starts from a clean, inert channel.
|
|
138
|
+
def reset_for_test!
|
|
139
|
+
@side = nil
|
|
140
|
+
@sdk_version = nil
|
|
141
|
+
@enabled = nil
|
|
142
|
+
@limiter = See::Limiter.new
|
|
143
|
+
@ingest_key = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Stand in a real-looking key so specs can exercise the send path without
|
|
147
|
+
# the (deliberately inert) placeholder blocking it.
|
|
148
|
+
def set_ingest_key_for_test(key)
|
|
149
|
+
@ingest_key = key
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/shipeasy/sdk/version.rb
CHANGED
data/lib/shipeasy-sdk.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "shipeasy/sdk/version"
|
|
2
|
+
require_relative "shipeasy/logging"
|
|
2
3
|
require_relative "shipeasy/config"
|
|
3
4
|
require_relative "shipeasy/sdk/murmur3"
|
|
4
5
|
require_relative "shipeasy/sdk/eval"
|
|
@@ -54,7 +55,7 @@ module Shipeasy
|
|
|
54
55
|
def self.see(problem)
|
|
55
56
|
client = default_client
|
|
56
57
|
if client.nil?
|
|
57
|
-
warn "[shipeasy] see() called before a client was created — error dropped"
|
|
58
|
+
Shipeasy::Logging.warn "[shipeasy] see() called before a client was created — error dropped"
|
|
58
59
|
return See::NullChain.new
|
|
59
60
|
end
|
|
60
61
|
client.see(problem)
|
|
@@ -64,7 +65,7 @@ module Shipeasy
|
|
|
64
65
|
def self.see_violation(name)
|
|
65
66
|
client = default_client
|
|
66
67
|
if client.nil?
|
|
67
|
-
warn "[shipeasy] see_violation() called before a client was created — error dropped"
|
|
68
|
+
Shipeasy::Logging.warn "[shipeasy] see_violation() called before a client was created — error dropped"
|
|
68
69
|
return See::NullChain.new
|
|
69
70
|
end
|
|
70
71
|
client.see_violation(name)
|
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:
|
|
4
|
+
version: 3.0.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-
|
|
11
|
+
date: 2026-07-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -140,8 +140,10 @@ files:
|
|
|
140
140
|
- lib/shipeasy/i18n/label_fetcher.rb
|
|
141
141
|
- lib/shipeasy/i18n/railtie.rb
|
|
142
142
|
- lib/shipeasy/i18n/view_helpers.rb
|
|
143
|
+
- lib/shipeasy/logging.rb
|
|
143
144
|
- lib/shipeasy/sdk/anon_id.rb
|
|
144
145
|
- lib/shipeasy/sdk/eval.rb
|
|
146
|
+
- lib/shipeasy/sdk/internal_report.rb
|
|
145
147
|
- lib/shipeasy/sdk/murmur3.rb
|
|
146
148
|
- lib/shipeasy/sdk/openfeature.rb
|
|
147
149
|
- lib/shipeasy/sdk/rack_middleware.rb
|