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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
4
- data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
3
+ metadata.gz: ff3dbc0f1f6f19484edafcb75460928392fd43278036b68ed2c8179f9d21ac4d
4
+ data.tar.gz: 04d7e38426d7be6c00e504749c6a33441d88db79f70e1eae533fa9fc115b94d4
5
5
  SHA512:
6
- metadata.gz: cff2bbf433472f1e2be585ccbf5deb12d420e29130bb8f09961f2b277135a0c1726c1318a0f1b7b57fce511b114b6fcde982cc0735b884bb946de46016638c52
7
- data.tar.gz: 1ff69897f3b0501e6c4e41fa5eb386017fc4d714a0a5423871ec867b969bea2d1097fda01fb3902b1a57bc37a0490aaf433169e9d829f11739f3ac63cbcabb56
6
+ metadata.gz: 93432ded1ed218653913a2895c0178741c4e4e06671e131722d45672f481d782791243fdc35d16347bd1462b2ea0cc2c8bdacdb7012d983a17f190c208159d9d
7
+ data.tar.gz: def56c79ef372df20b3a7325e838d8165955c39a2c8221e393546d60b61b9405ee2d5a346e2a467bc771d88c50af8806f116d1bfaac2eaed5db9f2e8f6cbd338
@@ -57,6 +57,19 @@ module Shipeasy
57
57
  end
58
58
  end
59
59
 
60
+ # Pick the bucketing identifier. When bucket_by is set and the user
61
+ # carries that attribute as a non-empty string (or any number, stringified),
62
+ # bucket on it — so a whole company/org lands on one variant. Otherwise fall
63
+ # back to user_id, then anonymous_id. Mirrors core's pickIdentifier.
64
+ def self.pick_identifier(user, bucket_by)
65
+ if bucket_by && !bucket_by.to_s.empty?
66
+ v = user[bucket_by] || user[bucket_by.to_sym]
67
+ return v if v.is_a?(String) && !v.empty?
68
+ return v.to_s if v.is_a?(Numeric)
69
+ end
70
+ user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
71
+ end
72
+
60
73
  def self.eval_gate(gate, user)
61
74
  return false if enabled?(gate["killswitch"])
62
75
  return false unless enabled?(gate["enabled"])
@@ -79,7 +92,14 @@ module Shipeasy
79
92
 
80
93
  ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
81
94
 
82
- def self.eval_experiment(exp, flags_blob, exps_blob, user)
95
+ # exp_name + sticky_store are optional so existing callers stay deterministic.
96
+ # When a sticky_store is passed, an enrolled unit whose stored salt prefix
97
+ # still matches skips the allocation gate (so a shrinking allocation keeps
98
+ # it in) and returns the stored group without re-running the pick. A fresh
99
+ # pick is persisted via store.set; a salt mismatch / missing stored group
100
+ # falls through to re-bucket + overwrite. Mirrors the TS reference
101
+ # (doc 20 §2). exp_name is the key under which the entry is stored.
102
+ def self.eval_experiment(exp, flags_blob, exps_blob, user, exp_name: nil, sticky_store: nil)
83
103
  not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
84
104
 
85
105
  return not_in unless exp && exp["status"] == "running"
@@ -90,7 +110,8 @@ module Shipeasy
90
110
  return not_in unless gate && eval_gate(gate, user)
91
111
  end
92
112
 
93
- uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
113
+ bucket_by = exp["bucketBy"] || exp[:bucketBy]
114
+ uid = pick_identifier(user, bucket_by)
94
115
  return not_in unless uid
95
116
 
96
117
  universe_name = exp["universe"]
@@ -103,14 +124,28 @@ module Shipeasy
103
124
 
104
125
  salt = exp["salt"]
105
126
  allocation_pct = exp["allocationPct"] || 0
127
+ groups = exp["groups"] || []
128
+ salt8 = (salt || "")[0, 8]
129
+
130
+ # Sticky short-circuit: an enrolled unit whose stored salt prefix still
131
+ # matches skips allocation and returns the stored group. If the stored
132
+ # group no longer exists, fall through to re-bucket + overwrite.
133
+ if sticky_store && exp_name
134
+ entry = (sticky_store.get(uid) || {})[exp_name]
135
+ if entry && entry["s"] == salt8
136
+ g = groups.find { |x| x["name"] == entry["g"] }
137
+ return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"]) if g
138
+ end
139
+ end
140
+
106
141
  return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
107
142
 
108
143
  group_hash = murmur3("#{salt}:group:#{uid}") % 10000
109
144
  cumulative = 0
110
- groups = exp["groups"] || []
111
145
  groups.each_with_index do |g, i|
112
146
  cumulative += g["weight"]
113
147
  if group_hash < cumulative || i == groups.length - 1
148
+ sticky_store.set(uid, exp_name, { "g" => g["name"], "s" => salt8 }) if sticky_store && exp_name
114
149
  return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
115
150
  end
116
151
  end
@@ -5,15 +5,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(exp, flags_blob, exps_blob, with_anon_id(user))
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
- **(props.empty? ? {} : { properties: props }),
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
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.4.0"
3
+ VERSION = "1.5.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -2,6 +2,7 @@ require_relative "shipeasy/sdk/version"
2
2
  require_relative "shipeasy/config"
3
3
  require_relative "shipeasy/sdk/murmur3"
4
4
  require_relative "shipeasy/sdk/eval"
5
+ require_relative "shipeasy/sdk/sticky_store"
5
6
  require_relative "shipeasy/sdk/flags_client"
6
7
  require_relative "shipeasy/sdk/anon_id"
7
8
  require_relative "shipeasy/sdk/rack_middleware"
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.0
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