shipeasy-sdk 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4eafaff1d5be1131bb55a64dbbb1653ee18f2a5a9e22cd83a45bb92de7bcfcb6
4
+ data.tar.gz: 4b59ebf58017deb5def949f46235d53f6ca60320400ac72ada320eff92d7e5d5
5
+ SHA512:
6
+ metadata.gz: cb3f19e0215c65fed6db82a1ef181949ef0cb666a25e02e67103b6840bc79ee3e855e9f784a99f03d9ca54c73a74f431e3d16fd90fca29561317ab4b80b200f0
7
+ data.tar.gz: 480ac5a6fdeaef418e34488a668e4431bafa392a54d7e8eae7373541a48d401bdb687408e0759f6269c7263c9d38a11b4b4877930df65b4cd7bde859472b0eca
data/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # shipeasy-sdk
2
+
3
+ Ruby SDK for the ShipEasy experiment platform. Evaluates feature flags and A/B experiments locally against blobs polled from the ShipEasy edge worker.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ # Gemfile
9
+ gem "shipeasy-sdk"
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```ruby
15
+ require "shipeasy-sdk"
16
+
17
+ client = Shipeasy::SDK.new_client(api_key: "sdk_live_xxxxx")
18
+ client.init # fetches flags/experiments + starts background polling thread
19
+
20
+ user = { user_id: "usr_123", plan: "pro", country: "US" }
21
+
22
+ # Feature flag
23
+ if client.get_flag("new_checkout", user)
24
+ # show new checkout
25
+ end
26
+
27
+ # Remote config
28
+ color = client.get_config("button_color") # => "blue" (raw value)
29
+
30
+ # A/B experiment
31
+ result = client.get_experiment("checkout_cta", user, { label: "Buy now" })
32
+ puts result.in_experiment # true/false
33
+ puts result.group # "control" | "treatment"
34
+ puts result.params # { "label" => "Checkout" }
35
+
36
+ # Track a metric event (fire-and-forget background thread)
37
+ client.track("usr_123", "checkout_completed", { revenue: 49.99 })
38
+
39
+ # Shutdown (stops background poll thread)
40
+ client.destroy
41
+ ```
42
+
43
+ ## init vs init_once
44
+
45
+ - `init` — fetches data, marks initialized, starts the background poll thread. Call once at app boot.
46
+ - `init_once` — same as `init` but is a no-op if already initialized. Safe to call multiple times (e.g. in middleware).
47
+
48
+ ## Evaluation details
49
+
50
+ - **Gates** — rules matched in order; rollout bucket computed as `murmur3("#{salt}:#{uid}") % 10000 < rolloutPct`.
51
+ - **Experiments** — checks `status == "running"`, optional targeting gate, universe holdout range, allocation bucket, then group assignment by weight.
52
+ - **MurmurHash3** — pure Ruby implementation, x86_32 variant, seed 0.
53
+ - **ETag caching** — each poll sends `If-None-Match`; a `304` response skips the JSON parse.
54
+ - **Poll interval** — initial default 30 s; overridden by `X-Poll-Interval` response header from the flags endpoint.
55
+
56
+ ## Configuration
57
+
58
+ | Parameter | Default | Description |
59
+ |------------|--------------------------------|-----------------------------------|
60
+ | `api_key` | (required) | SDK key from the ShipEasy dashboard |
61
+ | `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
@@ -0,0 +1,117 @@
1
+ require_relative "murmur3"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ module Eval
6
+ def self.murmur3(key)
7
+ Murmur3.hash32(key, 0)
8
+ end
9
+
10
+ def self.enabled?(v)
11
+ v == 1 || v == true
12
+ end
13
+
14
+ def self.to_num(v)
15
+ case v
16
+ when Numeric then v.finite? ? v : nil
17
+ when String
18
+ n = Float(v) rescue nil
19
+ n&.finite? ? n : nil
20
+ end
21
+ end
22
+
23
+ def self.match_rule(rule, user)
24
+ attr = rule["attr"] || rule[:attr]
25
+ op = rule["op"] || rule[:op]
26
+ value = rule["value"] || rule[:value]
27
+ actual = user[attr] || user[attr.to_sym]
28
+
29
+ case op
30
+ when "eq" then actual == value
31
+ when "neq" then actual != value
32
+ when "in" then Array(value).include?(actual)
33
+ when "not_in" then !Array(value).include?(actual)
34
+ when "contains"
35
+ if actual.is_a?(String) && value.is_a?(String)
36
+ actual.include?(value)
37
+ elsif actual.is_a?(Array)
38
+ actual.include?(value)
39
+ else
40
+ false
41
+ end
42
+ when "regex"
43
+ actual.is_a?(String) && value.is_a?(String) &&
44
+ Regexp.new(value).match?(actual) rescue false
45
+ when "gt", "gte", "lt", "lte"
46
+ a = to_num(actual)
47
+ b = to_num(value)
48
+ return false if a.nil? || b.nil?
49
+ case op
50
+ when "gt" then a > b
51
+ when "gte" then a >= b
52
+ when "lt" then a < b
53
+ when "lte" then a <= b
54
+ end
55
+ else
56
+ false
57
+ end
58
+ end
59
+
60
+ def self.eval_gate(gate, user)
61
+ return false if enabled?(gate["killswitch"])
62
+ return false unless enabled?(gate["enabled"])
63
+
64
+ (gate["rules"] || []).each do |rule|
65
+ return false unless match_rule(rule, user)
66
+ end
67
+
68
+ uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
69
+ return false unless uid
70
+
71
+ salt = gate["salt"] || gate[:salt]
72
+ murmur3("#{salt}:#{uid}") % 10000 < (gate["rolloutPct"] || gate[:rolloutPct] || 0)
73
+ end
74
+
75
+ ExperimentResult = Struct.new(:in_experiment, :group, :params, keyword_init: true)
76
+
77
+ def self.eval_experiment(exp, flags_blob, exps_blob, user)
78
+ not_in = ExperimentResult.new(in_experiment: false, group: "control", params: nil)
79
+
80
+ return not_in unless exp && exp["status"] == "running"
81
+
82
+ targeting_gate = exp["targetingGate"]
83
+ if targeting_gate && !targeting_gate.empty?
84
+ gate = flags_blob&.dig("gates", targeting_gate)
85
+ return not_in unless gate && eval_gate(gate, user)
86
+ end
87
+
88
+ uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
89
+ return not_in unless uid
90
+
91
+ universe_name = exp["universe"]
92
+ universe = exps_blob&.dig("universes", universe_name)
93
+ holdout = universe&.dig("holdout_range")
94
+ if holdout
95
+ seg = murmur3("#{universe_name}:#{uid}") % 10000
96
+ return not_in if seg >= holdout[0] && seg <= holdout[1]
97
+ end
98
+
99
+ salt = exp["salt"]
100
+ allocation_pct = exp["allocationPct"] || 0
101
+ return not_in if murmur3("#{salt}:alloc:#{uid}") % 10000 >= allocation_pct
102
+
103
+ group_hash = murmur3("#{salt}:group:#{uid}") % 10000
104
+ cumulative = 0
105
+ groups = exp["groups"] || []
106
+ groups.each_with_index do |g, i|
107
+ cumulative += g["weight"]
108
+ if group_hash < cumulative || i == groups.length - 1
109
+ return ExperimentResult.new(in_experiment: true, group: g["name"], params: g["params"])
110
+ end
111
+ end
112
+
113
+ not_in
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,169 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "thread"
5
+ require_relative "eval"
6
+
7
+ module Shipeasy
8
+ module SDK
9
+ class FlagsClient
10
+ DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
11
+
12
+ def initialize(api_key:, base_url: nil)
13
+ @api_key = api_key
14
+ @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
15
+ @flags_blob = nil
16
+ @exps_blob = nil
17
+ @flags_etag = nil
18
+ @exps_etag = nil
19
+ @poll_interval = 30
20
+ @mutex = Mutex.new
21
+ @timer = nil
22
+ @initialized = false
23
+ end
24
+
25
+ def init
26
+ fetch_all
27
+ @initialized = true
28
+ start_poll
29
+ end
30
+
31
+ def init_once
32
+ return if @initialized
33
+ fetch_all
34
+ @initialized = true
35
+ end
36
+
37
+ def destroy
38
+ @timer&.kill
39
+ @timer = nil
40
+ end
41
+
42
+ def get_flag(name, user)
43
+ gate = @mutex.synchronize { @flags_blob&.dig("gates", name) }
44
+ return false unless gate
45
+ Eval.eval_gate(gate, user.transform_keys(&:to_s))
46
+ end
47
+
48
+ def get_config(name, decode = nil)
49
+ entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
50
+ return nil unless entry
51
+ value = entry["value"]
52
+ decode ? decode.call(value) : value
53
+ end
54
+
55
+ def get_experiment(name, user, default_params, decode = nil)
56
+ flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
57
+ exp = exps_blob&.dig("experiments", name)
58
+ result = Eval.eval_experiment(exp, flags_blob, exps_blob, user.transform_keys(&:to_s))
59
+ result.params ||= default_params
60
+
61
+ if result.in_experiment && decode
62
+ begin
63
+ result = Eval::ExperimentResult.new(
64
+ in_experiment: true,
65
+ group: result.group,
66
+ params: decode.call(result.params),
67
+ )
68
+ rescue => e
69
+ warn "[shipeasy] get_experiment('#{name}') decode failed: #{e.message}"
70
+ return Eval::ExperimentResult.new(in_experiment: false, group: "control", params: default_params)
71
+ end
72
+ end
73
+
74
+ result
75
+ end
76
+
77
+ def track(user_id, event_name, props = {})
78
+ payload = JSON.generate({
79
+ events: [{
80
+ type: "metric",
81
+ event_name: event_name,
82
+ user_id: user_id.to_s,
83
+ ts: (Time.now.to_f * 1000).to_i,
84
+ **(props.empty? ? {} : { properties: props }),
85
+ }],
86
+ })
87
+
88
+ Thread.new do
89
+ post("/collect", payload)
90
+ rescue => e
91
+ warn "[shipeasy] track failed: #{e.message}"
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def start_poll
98
+ @timer = Thread.new do
99
+ loop do
100
+ sleep(@poll_interval)
101
+ begin
102
+ fetch_all
103
+ rescue => e
104
+ warn "[shipeasy] background poll failed: #{e.message}"
105
+ end
106
+ end
107
+ end
108
+ @timer.abort_on_exception = false
109
+ end
110
+
111
+ def fetch_all
112
+ flags_thread = Thread.new { fetch_flags }
113
+ fetch_exps
114
+ interval = flags_thread.value
115
+ if interval && interval != @poll_interval
116
+ @poll_interval = interval
117
+ end
118
+ end
119
+
120
+ def fetch_flags
121
+ headers = { "X-SDK-Key" => @api_key }
122
+ headers["If-None-Match"] = @flags_etag if @flags_etag
123
+ res = http_get("/sdk/flags", headers)
124
+ interval = (res["X-Poll-Interval"] || "30").to_i
125
+ return interval if res.code == "304"
126
+ raise "GET /sdk/flags returned #{res.code}" unless res.is_a?(Net::HTTPSuccess)
127
+ etag = res["ETag"]
128
+ blob = JSON.parse(res.body)
129
+ @mutex.synchronize do
130
+ @flags_etag = etag if etag
131
+ @flags_blob = blob
132
+ end
133
+ interval
134
+ end
135
+
136
+ def fetch_exps
137
+ headers = { "X-SDK-Key" => @api_key }
138
+ headers["If-None-Match"] = @exps_etag if @exps_etag
139
+ res = http_get("/sdk/experiments", headers)
140
+ return if res.code == "304"
141
+ raise "GET /sdk/experiments returned #{res.code}" unless res.is_a?(Net::HTTPSuccess)
142
+ etag = res["ETag"]
143
+ blob = JSON.parse(res.body)
144
+ @mutex.synchronize do
145
+ @exps_etag = etag if etag
146
+ @exps_blob = blob
147
+ end
148
+ end
149
+
150
+ def http_get(path, headers = {})
151
+ uri = URI.parse("#{@base_url}#{path}")
152
+ http = Net::HTTP.new(uri.host, uri.port)
153
+ http.use_ssl = (uri.scheme == "https")
154
+ http.open_timeout = 5
155
+ http.read_timeout = 10
156
+ http.get(uri.request_uri, headers)
157
+ end
158
+
159
+ def post(path, body)
160
+ uri = URI.parse("#{@base_url}#{path}")
161
+ http = Net::HTTP.new(uri.host, uri.port)
162
+ http.use_ssl = (uri.scheme == "https")
163
+ http.open_timeout = 5
164
+ http.read_timeout = 10
165
+ http.post(uri.request_uri, body, { "X-SDK-Key" => @api_key, "Content-Type" => "text/plain" })
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,54 @@
1
+ module Shipeasy
2
+ module SDK
3
+ module Murmur3
4
+ C1 = 0xcc9e2d51
5
+ C2 = 0x1b873593
6
+ MASK32 = 0xFFFFFFFF
7
+
8
+ def self.hash32(key, seed = 0)
9
+ bytes = key.encode("UTF-8").bytes
10
+ len = bytes.length
11
+ h1 = seed
12
+
13
+ (len / 4).times do |i|
14
+ off = i * 4
15
+ k1 = bytes[off] | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24)
16
+ k1 = (k1 & MASK32)
17
+ k1 = ((k1 * C1) & MASK32)
18
+ k1 = ((k1 << 15) | (k1 >> 17)) & MASK32
19
+ k1 = ((k1 * C2) & MASK32)
20
+ h1 ^= k1
21
+ h1 = ((h1 << 13) | (h1 >> 19)) & MASK32
22
+ h1 = ((h1 * 5) & MASK32)
23
+ h1 = (h1 + 0xe6546b64) & MASK32
24
+ end
25
+
26
+ tail = (len / 4) * 4
27
+ k1 = 0
28
+ remain = len & 3
29
+ k1 ^= (bytes[tail + 2] << 16) if remain >= 3
30
+ k1 ^= (bytes[tail + 1] << 8) if remain >= 2
31
+ k1 ^= bytes[tail] if remain >= 1
32
+ if remain > 0
33
+ k1 = (k1 * C1) & MASK32
34
+ k1 = ((k1 << 15) | (k1 >> 17)) & MASK32
35
+ k1 = (k1 * C2) & MASK32
36
+ h1 ^= k1
37
+ end
38
+
39
+ h1 ^= len
40
+ h1 = fmix32(h1)
41
+ h1
42
+ end
43
+
44
+ def self.fmix32(h)
45
+ h ^= (h >> 16)
46
+ h = (h * 0x85ebca6b) & MASK32
47
+ h ^= (h >> 13)
48
+ h = (h * 0xc2b2ae35) & MASK32
49
+ h ^= (h >> 16)
50
+ h
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ module Shipeasy
2
+ module SDK
3
+ VERSION = "1.0.0"
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ require_relative "shipeasy/sdk/version"
2
+ require_relative "shipeasy/sdk/murmur3"
3
+ require_relative "shipeasy/sdk/eval"
4
+ require_relative "shipeasy/sdk/flags_client"
5
+
6
+ module Shipeasy
7
+ module SDK
8
+ def self.new_client(api_key:, base_url: nil)
9
+ FlagsClient.new(api_key: api_key, base_url: base_url)
10
+ end
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,47 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shipeasy-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - ShipEasy
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Server SDK for ShipEasy — polls /sdk/flags and /sdk/experiments, evaluates
13
+ flags and experiments locally.
14
+ email:
15
+ - sdk@shipeasy.dev
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/shipeasy-sdk.rb
22
+ - lib/shipeasy/sdk/eval.rb
23
+ - lib/shipeasy/sdk/flags_client.rb
24
+ - lib/shipeasy/sdk/murmur3.rb
25
+ - lib/shipeasy/sdk/version.rb
26
+ homepage: https://github.com/shipeasy/sdk-ruby
27
+ licenses:
28
+ - MIT
29
+ metadata: {}
30
+ rdoc_options: []
31
+ require_paths:
32
+ - lib
33
+ required_ruby_version: !ruby/object:Gem::Requirement
34
+ requirements:
35
+ - - ">="
36
+ - !ruby/object:Gem::Version
37
+ version: 2.7.0
38
+ required_rubygems_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0'
43
+ requirements: []
44
+ rubygems_version: 4.0.6
45
+ specification_version: 4
46
+ summary: ShipEasy feature flag and experimentation SDK for Ruby
47
+ test_files: []