shipeasy-sdk 2.4.0 → 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 +26 -13
- data/lib/shipeasy/config.rb +10 -2
- data/lib/shipeasy/engine.rb +177 -85
- data/lib/shipeasy/logging.rb +1 -1
- 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
- metadata +2 -1
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,
|
|
@@ -62,11 +62,31 @@ module Shipeasy
|
|
|
62
62
|
default
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
73
|
+
end
|
|
74
|
+
|
|
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
|
|
70
90
|
end
|
|
71
91
|
|
|
72
92
|
# Killswitches are not user-scoped; forwarded straight to the engine.
|
|
@@ -84,13 +104,6 @@ module Shipeasy
|
|
|
84
104
|
Shipeasy::Logging.error "[shipeasy] Client#track('#{event_name}') failed: #{e.message}"
|
|
85
105
|
nil
|
|
86
106
|
end
|
|
87
|
-
|
|
88
|
-
def log_exposure(experiment_name)
|
|
89
|
-
@engine.log_exposure(@attributes, experiment_name)
|
|
90
|
-
rescue StandardError => e
|
|
91
|
-
Shipeasy::Logging.error "[shipeasy] Client#log_exposure('#{experiment_name}') failed: #{e.message}"
|
|
92
|
-
nil
|
|
93
|
-
end
|
|
94
107
|
end
|
|
95
108
|
|
|
96
109
|
# Raised by Shipeasy::Client when constructed before Shipeasy.configure.
|
data/lib/shipeasy/config.rb
CHANGED
|
@@ -30,8 +30,14 @@ 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
|
|
35
41
|
|
|
36
42
|
# SDK-wide diagnostic verbosity for the leveled logger (Shipeasy::Logging).
|
|
37
43
|
# One of :silent, :error, :warn (default), :info, :debug (strings accepted
|
|
@@ -69,12 +75,13 @@ module Shipeasy
|
|
|
69
75
|
:manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
|
|
70
76
|
|
|
71
77
|
def initialize
|
|
72
|
-
@base_url = "https://
|
|
78
|
+
@base_url = "https://api.shipeasy.ai"
|
|
73
79
|
@attributes = nil
|
|
74
80
|
@init = true
|
|
75
81
|
@poll = false
|
|
76
82
|
@env = "prod"
|
|
77
83
|
@disable_telemetry = false
|
|
84
|
+
@disable_internal_error_reporting = false
|
|
78
85
|
@telemetry_url = nil
|
|
79
86
|
@private_attributes = nil
|
|
80
87
|
@sticky_store = nil
|
|
@@ -157,6 +164,7 @@ module Shipeasy
|
|
|
157
164
|
private_attributes: cfg.private_attributes,
|
|
158
165
|
sticky_store: cfg.sticky_store,
|
|
159
166
|
log_level: cfg.log_level,
|
|
167
|
+
disable_internal_error_reporting: cfg.disable_internal_error_reporting,
|
|
160
168
|
)
|
|
161
169
|
@engine = engine
|
|
162
170
|
# Capture +engine+ in the closure (not the @engine ivar, which a concurrent
|
data/lib/shipeasy/engine.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "sdk/telemetry"
|
|
|
9
9
|
require_relative "sdk/anon_id"
|
|
10
10
|
require_relative "sdk/sticky_store"
|
|
11
11
|
require_relative "sdk/see"
|
|
12
|
+
require_relative "sdk/internal_report"
|
|
12
13
|
|
|
13
14
|
module Shipeasy
|
|
14
15
|
# The heavyweight engine: owns the api key, HTTP transport, the blob cache,
|
|
@@ -29,12 +30,12 @@ module Shipeasy
|
|
|
29
30
|
AnonId = Shipeasy::SDK::AnonId
|
|
30
31
|
See = Shipeasy::SDK::See
|
|
31
32
|
|
|
32
|
-
DEFAULT_BASE_URL = "https://
|
|
33
|
+
DEFAULT_BASE_URL = "https://api.shipeasy.ai"
|
|
33
34
|
# CDN origin serving the static loader scripts (/sdk/bootstrap.js,
|
|
34
35
|
# /sdk/i18n/loader.js) — distinct from the edge API the blobs are fetched from.
|
|
35
36
|
DEFAULT_CDN_BASE = "https://cdn.shipeasy.ai"
|
|
36
37
|
|
|
37
|
-
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)
|
|
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)
|
|
38
39
|
# SDK-wide diagnostic verbosity. Set the leveled logger from the passed
|
|
39
40
|
# level (default :warn; unknown falls back to :warn). The logger is
|
|
40
41
|
# module-scoped, so the last-built engine wins — mirrors the TS SDK,
|
|
@@ -51,7 +52,7 @@ module Shipeasy
|
|
|
51
52
|
# track(), where the listed keys are dropped from the props bag.
|
|
52
53
|
@private_attributes = (private_attributes || []).map(&:to_s)
|
|
53
54
|
# Pluggable sticky-bucketing store (doc 20 §2). Absent ⇒ deterministic.
|
|
54
|
-
# Threaded into
|
|
55
|
+
# Threaded into experiment eval so an enrolled unit locks to its first
|
|
55
56
|
# assigned variant. Built-in: InMemoryStickyStore.
|
|
56
57
|
@sticky_store = sticky_store
|
|
57
58
|
# Test mode: no network, ever. init/init_once/track become no-ops and
|
|
@@ -88,6 +89,22 @@ module Shipeasy
|
|
|
88
89
|
# see() structured error reporting. Per-process spam guard, bound here so
|
|
89
90
|
# repeated reports of the same issue collapse to one send. See see.rb.
|
|
90
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
|
+
)
|
|
91
108
|
# Register as the default client backing the module-level Shipeasy::SDK
|
|
92
109
|
# .see/.see_violation funcs (last constructed wins — the server-SDK
|
|
93
110
|
# analog of TS's shipeasy({key}) configure call).
|
|
@@ -283,48 +300,68 @@ module Shipeasy
|
|
|
283
300
|
end
|
|
284
301
|
end
|
|
285
302
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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])
|
|
302
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
|
|
303
344
|
end
|
|
345
|
+
end
|
|
304
346
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
)
|
|
312
|
-
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
|
|
313
353
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
)
|
|
321
|
-
rescue => e
|
|
322
|
-
Shipeasy::Logging.warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
|
|
323
|
-
return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
|
|
324
|
-
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
|
|
325
360
|
end
|
|
326
361
|
|
|
327
|
-
|
|
362
|
+
def assign(user)
|
|
363
|
+
@engine.assign_universe(@name, user)
|
|
364
|
+
end
|
|
328
365
|
end
|
|
329
366
|
|
|
330
367
|
# Public hook for the bound Shipeasy::Client: normalise an attribute hash
|
|
@@ -363,9 +400,8 @@ module Shipeasy
|
|
|
363
400
|
# for this SDK. No telemetry (a batch evaluate is not a per-flag exposure).
|
|
364
401
|
def evaluate(user)
|
|
365
402
|
u = with_anon_id(user)
|
|
366
|
-
flags_blob, exps_blob, flag_ov, config_ov
|
|
367
|
-
[@flags_blob, @exps_blob, @flag_overrides.dup, @config_overrides.dup
|
|
368
|
-
@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]
|
|
369
405
|
end
|
|
370
406
|
|
|
371
407
|
flags = {}
|
|
@@ -378,18 +414,30 @@ module Shipeasy
|
|
|
378
414
|
configs[name] = config_ov.key?(name) ? config_ov[name] : entry["value"]
|
|
379
415
|
end
|
|
380
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.
|
|
381
420
|
experiments = {}
|
|
421
|
+
universes = {}
|
|
382
422
|
(exps_blob&.dig("experiments") || {}).each do |name, exp|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
+
}
|
|
387
429
|
end
|
|
388
|
-
r =
|
|
389
|
-
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
|
+
}
|
|
390
437
|
end
|
|
391
438
|
|
|
392
|
-
{ "flags" => flags, "configs" => configs, "experiments" => experiments,
|
|
439
|
+
{ "flags" => flags, "configs" => configs, "experiments" => experiments,
|
|
440
|
+
"killswitches" => {}, "universes" => universes }
|
|
393
441
|
end
|
|
394
442
|
|
|
395
443
|
# Return the cross-platform SSR bootstrap <script> tag for a request:
|
|
@@ -445,40 +493,6 @@ module Shipeasy
|
|
|
445
493
|
end
|
|
446
494
|
end
|
|
447
495
|
|
|
448
|
-
# Emit an exposure event for an experiment at the server-side decision
|
|
449
|
-
# point (parity with the browser's auto-exposure). The server is stateless
|
|
450
|
-
# and never auto-logs, so call this when you actually present the
|
|
451
|
-
# treatment. Re-evaluates the experiment for the user (a bare user_id
|
|
452
|
-
# string is wrapped as { "user_id" => id }); if enrolled, POSTs a single
|
|
453
|
-
# exposure to /collect. No-op in test mode or when the user isn't enrolled.
|
|
454
|
-
def log_exposure(user_or_user_id, experiment_name)
|
|
455
|
-
safe_run("log_exposure('#{experiment_name}')", nil) do
|
|
456
|
-
next if @test_mode
|
|
457
|
-
|
|
458
|
-
user = user_or_user_id.is_a?(Hash) ? user_or_user_id : { "user_id" => user_or_user_id.to_s }
|
|
459
|
-
result = get_experiment(experiment_name, user, {})
|
|
460
|
-
next unless result.in_experiment
|
|
461
|
-
|
|
462
|
-
u = user.transform_keys(&:to_s)
|
|
463
|
-
payload = JSON.generate({
|
|
464
|
-
events: [{
|
|
465
|
-
type: "exposure",
|
|
466
|
-
experiment: experiment_name.to_s,
|
|
467
|
-
group: result.group,
|
|
468
|
-
user_id: (u["user_id"] || u["anonymous_id"]).to_s,
|
|
469
|
-
ts: (Time.now.to_f * 1000).to_i,
|
|
470
|
-
}],
|
|
471
|
-
})
|
|
472
|
-
|
|
473
|
-
Thread.new do
|
|
474
|
-
post("/collect", payload)
|
|
475
|
-
rescue => e
|
|
476
|
-
Shipeasy::Logging.warn "[shipeasy] log_exposure failed: #{e.message}"
|
|
477
|
-
end
|
|
478
|
-
nil
|
|
479
|
-
end
|
|
480
|
-
end
|
|
481
|
-
|
|
482
496
|
# ---- see() structured error reporting -------------------------------
|
|
483
497
|
|
|
484
498
|
# Report a caught exception (or thrown non-exception). Fire-and-forget;
|
|
@@ -507,8 +521,8 @@ module Shipeasy
|
|
|
507
521
|
private
|
|
508
522
|
|
|
509
523
|
# Last-resort guard that makes a public RUNTIME method (get_flag /
|
|
510
|
-
# get_config /
|
|
511
|
-
#
|
|
524
|
+
# get_config / assign_universe / get_killswitch / track) unable to raise
|
|
525
|
+
# into product code, even if an internal invariant is
|
|
512
526
|
# violated. Runs the block; on any StandardError it logs at :error and
|
|
513
527
|
# returns +fallback+ (the method's documented safe default). +label+ names
|
|
514
528
|
# the method for the log line.
|
|
@@ -516,9 +530,87 @@ module Shipeasy
|
|
|
516
530
|
yield
|
|
517
531
|
rescue StandardError => e
|
|
518
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)
|
|
519
540
|
fallback
|
|
520
541
|
end
|
|
521
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
|
+
|
|
522
614
|
# Build the wire event and fire-and-forget POST it to /collect. No-op in
|
|
523
615
|
# test mode (mirrors track). Spam-guarded. Never raises into caller code.
|
|
524
616
|
def dispatch_see(built)
|
data/lib/shipeasy/logging.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
# Every diagnostic the SDK emits from a *caught* error goes through here, so a
|
|
4
4
|
# single `log_level` config option (default :warn) controls the SDK's stderr
|
|
5
5
|
# output. The contract for the SDK's public RUNTIME methods (get_flag,
|
|
6
|
-
# get_config,
|
|
6
|
+
# get_config, universe(...).assign, get_killswitch, track, see, …) is
|
|
7
7
|
# that they NEVER raise into product code — so logging itself is best-effort
|
|
8
8
|
# too: a broken/throwing $stderr can never take down a flag read.
|
|
9
9
|
#
|
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
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.
|
|
@@ -143,6 +143,7 @@ files:
|
|
|
143
143
|
- lib/shipeasy/logging.rb
|
|
144
144
|
- lib/shipeasy/sdk/anon_id.rb
|
|
145
145
|
- lib/shipeasy/sdk/eval.rb
|
|
146
|
+
- lib/shipeasy/sdk/internal_report.rb
|
|
146
147
|
- lib/shipeasy/sdk/murmur3.rb
|
|
147
148
|
- lib/shipeasy/sdk/openfeature.rb
|
|
148
149
|
- lib/shipeasy/sdk/rack_middleware.rb
|