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