shipeasy-sdk 1.0.0 → 1.4.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: 4eafaff1d5be1131bb55a64dbbb1653ee18f2a5a9e22cd83a45bb92de7bcfcb6
4
- data.tar.gz: 4b59ebf58017deb5def949f46235d53f6ca60320400ac72ada320eff92d7e5d5
3
+ metadata.gz: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
4
+ data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
5
5
  SHA512:
6
- metadata.gz: cb3f19e0215c65fed6db82a1ef181949ef0cb666a25e02e67103b6840bc79ee3e855e9f784a99f03d9ca54c73a74f431e3d16fd90fca29561317ab4b80b200f0
7
- data.tar.gz: 480ac5a6fdeaef418e34488a668e4431bafa392a54d7e8eae7373541a48d401bdb687408e0759f6269c7263c9d38a11b4b4877930df65b4cd7bde859472b0eca
6
+ metadata.gz: cff2bbf433472f1e2be585ccbf5deb12d420e29130bb8f09961f2b277135a0c1726c1318a0f1b7b57fce511b114b6fcde982cc0735b884bb946de46016638c52
7
+ data.tar.gz: 1ff69897f3b0501e6c4e41fa5eb386017fc4d714a0a5423871ec867b969bea2d1097fda01fb3902b1a57bc37a0490aaf433169e9d829f11739f3ac63cbcabb56
data/LICENSE ADDED
@@ -0,0 +1,40 @@
1
+ Shipeasy Source-Available License (Shipeasy-SAL) 1.0
2
+
3
+ Copyright (c) 2026 Shipeasy, Inc. All rights reserved.
4
+
5
+ 1. License Grant.
6
+ Subject to the terms of this License, Shipeasy, Inc. ("Shipeasy") grants
7
+ you a non-exclusive, non-transferable, revocable, worldwide license to:
8
+
9
+ (a) Use, copy, and modify the Software solely as a client integration for
10
+ interacting with Shipeasy's hosted services (the "Service");
11
+ (b) Distribute the Software as part of an application that calls the
12
+ Service, in object form, provided the recipient also agrees to this
13
+ License.
14
+
15
+ 2. Restrictions.
16
+ You may not:
17
+
18
+ (a) Use the Software, in whole or in part, to build, host, or operate any
19
+ service that competes with the Service or that provides feature-flag,
20
+ experimentation, configuration, internationalization, or related
21
+ functionality to third parties on a commercial basis;
22
+ (b) Sublicense, sell, rent, or lease the Software;
23
+ (c) Remove or alter copyright notices, license terms, or attribution.
24
+
25
+ 3. Contributions.
26
+ Any pull request you submit is licensed back to Shipeasy under this
27
+ License plus a perpetual, irrevocable right for Shipeasy to relicense.
28
+
29
+ 4. Trademarks.
30
+ This License does not grant rights in the names "Shipeasy", related
31
+ marks, or logos.
32
+
33
+ 5. No Warranty / Limitation of Liability.
34
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO
35
+ EVENT SHALL SHIPEASY BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
36
+ LIABILITY ARISING FROM USE OF THE SOFTWARE.
37
+
38
+ 6. Termination.
39
+ This License terminates automatically if you breach it. Sections 2-5
40
+ survive termination.
data/README.md CHANGED
@@ -1,61 +1,283 @@
1
- # shipeasy-sdk
1
+ # shipeasy-sdk (Ruby)
2
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.
3
+ Ruby gem for the [Shipeasy](https://shipeasy.ai) hosted service. Server-side
4
+ gate evaluation, runtime configs, experiments, and metric ingestion.
4
5
 
5
- ## Installation
6
+ > Source-available under the [Shipeasy-SAL 1.0](./LICENSE).
7
+
8
+ ## Install
6
9
 
7
10
  ```ruby
8
11
  # Gemfile
9
12
  gem "shipeasy-sdk"
10
13
  ```
11
14
 
12
- ## Quick start
15
+ ## Quickstart (Rails)
16
+
17
+ `config/initializers/shipeasy.rb` is all you need:
13
18
 
14
19
  ```ruby
15
- require "shipeasy-sdk"
20
+ Shipeasy.configure do |c|
21
+ c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
22
+ c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
23
+ c.profile = "default"
24
+ end
25
+ ```
16
26
 
17
- client = Shipeasy::SDK.new_client(api_key: "sdk_live_xxxxx")
18
- client.init # fetches flags/experiments + starts background polling thread
27
+ Anywhere in your app:
19
28
 
20
- user = { user_id: "usr_123", plan: "pro", country: "US" }
29
+ ```ruby
30
+ user = { user_id: current_user.id, plan: current_user.plan }
21
31
 
22
- # Feature flag
23
- if client.get_flag("new_checkout", user)
24
- # show new checkout
32
+ if Shipeasy.flags.get_flag("new_checkout", user)
33
+ # ship it
25
34
  end
26
35
 
27
- # Remote config
28
- color = client.get_config("button_color") # => "blue" (raw value)
36
+ color = Shipeasy.flags.get_config("button_color")
37
+ result = Shipeasy.flags.get_experiment("checkout_cta", user, { label: "Buy now" })
38
+ Shipeasy.flags.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
39
+ ```
40
+
41
+ `Shipeasy.flags` is a lazy, **fork-safe** singleton: the first call from
42
+ each process spawns its own `FlagsClient` and starts the background poll
43
+ thread, including post-fork Puma workers under `preload_app!`. No need
44
+ for `before_worker_boot` hooks or holding a global constant.
45
+
46
+ In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
47
+
48
+ ```erb
49
+ <%= i18n_head_tags %>
50
+ <h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
51
+ ```
52
+
53
+ ### Anonymous visitors (zero-config bucketing)
54
+
55
+ For logged-out traffic you need a *stable* unit so a fractional rollout buckets
56
+ the same on the server and in the browser. In Rails this is automatic: a Railtie
57
+ mounts `Shipeasy::SDK::RackMiddleware`, which mints the shared `__se_anon_id`
58
+ first-party cookie (read + written by every Shipeasy SDK, including the browser)
59
+ for any request without one. Evaluations then default to it with **no per-call
60
+ wiring** — `get_flag` on an anonymous request just works:
61
+
62
+ ```ruby
63
+ # current_user is nil → buckets on the __se_anon_id cookie automatically
64
+ Shipeasy.flags.get_flag("new_checkout", {})
65
+ ```
66
+
67
+ An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
68
+ yourself it's also on the Rack env as `request.env["shipeasy.anon_id"]`. The
69
+ cookie is non-`HttpOnly` by design so the browser SDK can bucket identically. A
70
+ request with **no** unit still resolves a fully-rolled (100%) gate as on; only
71
+ fractional gates need the id. Cookie name + format are a cross-SDK contract —
72
+ see `18-identity-bucketing.md`.
73
+
74
+ For **Sinatra / Hanami / bare Rack** (no Railtie), mount it yourself:
75
+
76
+ ```ruby
77
+ use Shipeasy::SDK::RackMiddleware
78
+ ```
79
+
80
+ ## Quickstart (plain Ruby / Sinatra / Hanami / scripts)
81
+
82
+ Same pattern, just without `config/initializers`:
83
+
84
+ ```ruby
85
+ require "shipeasy-sdk"
86
+
87
+ Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
88
+
89
+ Shipeasy.flags.get_flag("new_checkout", { user_id: "u_1" })
90
+ ```
91
+
92
+ The Rails view helpers (`i18n_*`) are not loaded outside Rails, so the
93
+ gem doesn't pull Rails into Sinatra/Hanami apps.
94
+
95
+ ## Lambda / Cloud Run / serverless
96
+
97
+ Skip the auto-init facade — it spawns a poll thread you don't want in a
98
+ short-lived function. Build the client explicitly and call `init_once`
99
+ for a single synchronous fetch:
100
+
101
+ ```ruby
102
+ client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
103
+ client.init_once
104
+ client.get_flag("new_checkout", user)
105
+ ```
106
+
107
+ ## Lifecycle escape hatch
108
+
109
+ If you want explicit shutdown control in a long-running worker, build the
110
+ client yourself and skip the singleton:
111
+
112
+ ```ruby
113
+ client = Shipeasy::SDK.new_client # reads api_key + base_url from Shipeasy.config
114
+ client.init
115
+ at_exit { client.destroy }
116
+ ```
117
+
118
+ ## Default values
119
+
120
+ `get_flag` and `get_config` take an optional `default:` returned **only when the
121
+ value cannot be resolved** — never when a flag genuinely evaluates to `false`.
29
122
 
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" }
123
+ ```ruby
124
+ # Flag: default is returned only when the client isn't ready yet (no blob
125
+ # fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
126
+ # or outside its rollout) returns false, NOT the default.
127
+ Shipeasy.flags.get_flag("new_checkout", user, default: true)
128
+
129
+ # Config: default is returned when the config key is absent. A decode proc still
130
+ # runs on a present value.
131
+ Shipeasy.flags.get_config("button_color", default: "blue")
132
+ Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)
133
+ ```
35
134
 
36
- # Track a metric event (fire-and-forget background thread)
37
- client.track("usr_123", "checkout_completed", { revenue: 49.99 })
135
+ ## Evaluation detail
38
136
 
39
- # Shutdown (stops background poll thread)
40
- client.destroy
137
+ `get_flag_detail(name, user)` returns the boolean **and the reason** it was
138
+ reached, as a `FlagDetail` struct (`.value`, `.reason`). `get_flag` is built on
139
+ top of it. The reason is one of the `REASON_*` constants:
140
+
141
+ | Reason | Meaning |
142
+ | ------------------ | --------------------------------------------------- |
143
+ | `OVERRIDE` | answered by a local `override_flag` (no telemetry) |
144
+ | `CLIENT_NOT_READY` | no flag blob fetched/loaded yet |
145
+ | `FLAG_NOT_FOUND` | blob present, but this gate isn't in it |
146
+ | `OFF` | gate present but disabled or killswitched |
147
+ | `RULE_MATCH` | evaluated to `true` |
148
+ | `DEFAULT` | evaluated to `false` (rollout/rule) |
149
+
150
+ ```ruby
151
+ detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
152
+ detail.value # => true / false
153
+ detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...
41
154
  ```
42
155
 
43
- ## init vs init_once
156
+ The `gate` usage beacon fires exactly once per `get_flag_detail` call (never on
157
+ the `OVERRIDE` short-circuit).
158
+
159
+ ## Change listeners
160
+
161
+ `on_change` registers a callback fired after a background poll fetches **new**
162
+ flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
163
+ returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
164
+ no poll thread). A raising listener is isolated and logged, not propagated.
165
+
166
+ ```ruby
167
+ unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
168
+ # ... later
169
+ unsubscribe.call
170
+ ```
44
171
 
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).
172
+ ## Offline snapshot
173
+
174
+ For CI, air-gapped runs, or reproducing a production decision from a captured
175
+ blob, build a **no-network** client that still runs the real evaluator against a
176
+ snapshot. The snapshot JSON holds the raw response bodies of the two SDK
177
+ endpoints:
178
+
179
+ ```json
180
+ { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
181
+ ```
182
+
183
+ ```ruby
184
+ client = Shipeasy::SDK::FlagsClient.from_file("snapshot.json")
185
+ # or, from already-parsed blobs:
186
+ client = Shipeasy::SDK::FlagsClient.from_snapshot(flags: flags_body, experiments: exps_body)
187
+
188
+ client.get_flag("new_checkout", user) # real evaluation, no network
189
+ client.get_experiment("checkout_cta", user, {})
190
+ ```
191
+
192
+ `init` / `init_once` / `track` are no-ops and telemetry is off (it reuses the
193
+ `for_testing` plumbing). Local `override_*` setters still apply on top of the
194
+ snapshot.
47
195
 
48
196
  ## Evaluation details
49
197
 
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.
198
+ - **Gates** — rules matched in order; rollout bucket =
199
+ `murmur3("#{salt}:#{uid}") % 10000 < rollout_pct`.
200
+ - **Experiments** — `status == "running"`, optional targeting gate,
201
+ universe holdout range, allocation bucket, then group assignment by
202
+ weight.
203
+ - **MurmurHash3** — pure-Ruby x86_32 variant, seed 0.
204
+ - **ETag caching** — each poll sends `If-None-Match`; a 304 skips the
205
+ JSON parse.
206
+ - **Poll interval** — defaults to 30 s; overridden by the
207
+ `X-Poll-Interval` header from the flags endpoint.
208
+
209
+ ## Testing
210
+
211
+ For unit/integration tests you want a client that does **zero network** and
212
+ returns exactly the values you seed — no api_key, no fetch, no poll thread, no
213
+ telemetry, no metric ingestion. Build one with `FlagsClient.for_testing` and
214
+ seed each entity with the `override_*` setters (Statsig-style local overrides).
215
+ An override always wins over the fetched blob, so the getters answer
216
+ deterministically:
217
+
218
+ ```ruby
219
+ require "shipeasy-sdk"
220
+
221
+ client = Shipeasy::SDK::FlagsClient.for_testing
222
+ # init / init_once are no-ops here — nothing is ever fetched.
223
+
224
+ # Flags (boolean)
225
+ client.override_flag("new_checkout", true)
226
+ client.get_flag("new_checkout", { user_id: "u_1" }) # => true
227
+
228
+ # Configs (any value; an optional decode proc still runs)
229
+ client.override_config("button_color", "blue")
230
+ client.get_config("button_color") # => "blue"
231
+ client.override_config("limits", { "max" => 10 })
232
+ client.get_config("limits", ->(v) { v["max"] }) # => 10
233
+
234
+ # Experiments — returns an in-experiment Eval::ExperimentResult
235
+ client.override_experiment("checkout_cta", "treatment", { label: "Buy now" })
236
+ r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
237
+ r.in_experiment # => true
238
+ r.group # => "treatment"
239
+ r.params # => { label: "Buy now" }
240
+
241
+ # track is a no-op (no thread, no network) — assert call counts without stubbing.
242
+ client.track("u_1", "checkout_completed", { revenue: 49.99 }) # => nil
243
+
244
+ # Reset between examples
245
+ client.clear_overrides
246
+ ```
247
+
248
+ The same `override_flag` / `override_config` / `override_experiment` /
249
+ `clear_overrides` setters also work on a **normal** live client (built with
250
+ `FlagsClient.new(...)`), so you can pin one value in local development while the
251
+ rest comes from the fetched blob.
252
+
253
+ ### Rails singleton
254
+
255
+ `Shipeasy.flags` is a process-wide singleton that fetches over the network, so
256
+ in tests prefer a `for_testing` client. If a code path reaches through
257
+ `Shipeasy.flags` directly, stub the singleton to the test client in your test
258
+ setup:
259
+
260
+ ```ruby
261
+ # RSpec
262
+ before do
263
+ test_client = Shipeasy::SDK::FlagsClient.for_testing
264
+ test_client.override_flag("new_checkout", true)
265
+ allow(Shipeasy).to receive(:flags).and_return(test_client)
266
+ end
267
+ ```
55
268
 
56
269
  ## Configuration
57
270
 
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 |
271
+ | Parameter | Default | Description |
272
+ | ---------- | ------------------------- | ----------------------------------- |
273
+ | `api_key` | (required) | SDK key from the Shipeasy dashboard |
274
+ | `base_url` | `https://cdn.shipeasy.ai` | Override for local dev / staging |
275
+
276
+ ## Documentation
277
+
278
+ [docs.shipeasy.ai](https://docs.shipeasy.ai)
279
+
280
+ ## License
281
+
282
+ [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use,
283
+ permitted as a Shipeasy client.
@@ -0,0 +1,93 @@
1
+ # Single configuration object for the Shipeasy gem.
2
+ #
3
+ # Covers both subsystems:
4
+ # - SDK / experimentation (api_key, base_url) — drives FlagsClient
5
+ # - i18n / string manager (public_key, profile, cdn_base_url, ...) — drives
6
+ # the Rails view helpers and label fetcher
7
+ #
8
+ # Usage:
9
+ #
10
+ # Shipeasy.configure do |c|
11
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
12
+ # c.public_key = ENV["SHIPEASY_CLIENT_KEY"]
13
+ # c.profile = "default"
14
+ # end
15
+ #
16
+ # Anything not set falls back to the defaults below. The same Shipeasy.config
17
+ # is read by FlagsClient and the Rails helpers, so there is one place to
18
+ # point environment variables at.
19
+
20
+ module Shipeasy
21
+ class Configuration
22
+ # ---- experimentation / SDK ----
23
+ attr_accessor :api_key, :base_url
24
+
25
+ # ---- i18n / string manager ----
26
+ attr_accessor :public_key, :profile, :default_chunk,
27
+ :cdn_base_url, :loader_url,
28
+ :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
29
+
30
+ def initialize
31
+ @base_url = "https://edge.shipeasy.dev"
32
+
33
+ @profile = "default"
34
+ @default_chunk = "index"
35
+ @cdn_base_url = "https://cdn.i18n.shipeasy.ai"
36
+ @loader_url = "https://cdn.i18n.shipeasy.ai/loader.js"
37
+ @manifest_cache_ttl = 60
38
+ @label_file_cache_ttl = 3600
39
+ @http_timeout = 1
40
+ end
41
+ end
42
+
43
+ class << self
44
+ def config
45
+ @config ||= Configuration.new
46
+ end
47
+
48
+ def configure
49
+ yield config
50
+ end
51
+
52
+ # Reset the config back to defaults — primarily for tests.
53
+ def reset_config!
54
+ @config = nil
55
+ @flags_pid = nil
56
+ @flags&.destroy
57
+ @flags = nil
58
+ end
59
+
60
+ # Lazy, fork-safe singleton FlagsClient. The first call from each
61
+ # process spawns a fresh client + poll thread — including post-fork
62
+ # workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
63
+ # straight from a controller without holding a constant or worrying
64
+ # about `before_worker_boot` hooks.
65
+ #
66
+ # Initializers stay minimal:
67
+ #
68
+ # # config/initializers/shipeasy.rb
69
+ # Shipeasy.configure { |c| c.api_key = ENV["SHIPEASY_SERVER_KEY"] }
70
+ #
71
+ # The first request that touches `Shipeasy.flags.*` triggers init().
72
+ # For serverless / Lambda where you want a single fetch with no thread,
73
+ # build the client explicitly: `Shipeasy::SDK::FlagsClient.new(...).init_once`.
74
+ def flags
75
+ pid = Process.pid
76
+ if @flags && @flags_pid != pid
77
+ # Post-fork: parent's poll thread didn't survive. Don't destroy
78
+ # @flags (its mutex/state is invalid in this child anyway); just
79
+ # rebuild from scratch.
80
+ @flags = nil
81
+ end
82
+ @flags ||= begin
83
+ @flags_pid = pid
84
+ client = SDK::FlagsClient.new(
85
+ api_key: config.api_key,
86
+ base_url: config.base_url,
87
+ )
88
+ client.init
89
+ client
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,66 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Shipeasy
7
+ module I18n
8
+ class LabelFetcher
9
+ MANIFEST_KEY_PREFIX = "i18n:manifest:"
10
+ LABEL_KEY_PREFIX = "i18n:label:"
11
+
12
+ def initialize(config = Shipeasy.config)
13
+ @config = config
14
+ end
15
+
16
+ def fetch(profile: @config.profile, chunk: @config.default_chunk)
17
+ manifest = fetch_manifest(profile)
18
+ return nil unless manifest
19
+
20
+ file_url = manifest[chunk]
21
+ return nil unless file_url
22
+
23
+ fetch_label_file(file_url)
24
+ rescue => e
25
+ ::Rails.logger.warn("[Shipeasy::I18n] Failed to fetch labels: #{e.message}") if defined?(::Rails)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_manifest(profile)
32
+ cache_key = "#{MANIFEST_KEY_PREFIX}#{@config.public_key}:#{profile}"
33
+ cache_fetch(cache_key, @config.manifest_cache_ttl) do
34
+ url = "#{@config.cdn_base_url}/labels/#{@config.public_key}/#{profile}/manifest.json"
35
+ http_get_json(url)
36
+ end
37
+ end
38
+
39
+ def fetch_label_file(url)
40
+ cache_key = "#{LABEL_KEY_PREFIX}#{Digest::MD5.hexdigest(url)}"
41
+ cache_fetch(cache_key, @config.label_file_cache_ttl) do
42
+ http_get_json(url)
43
+ end
44
+ end
45
+
46
+ def cache_fetch(key, ttl, &block)
47
+ if defined?(::Rails) && ::Rails.cache
48
+ ::Rails.cache.fetch(key, expires_in: ttl.seconds, &block)
49
+ else
50
+ block.call
51
+ end
52
+ end
53
+
54
+ def http_get_json(url)
55
+ uri = URI.parse(url)
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.use_ssl = (uri.scheme == "https")
58
+ http.open_timeout = @config.http_timeout
59
+ http.read_timeout = @config.http_timeout
60
+ res = http.get(uri.request_uri, { "Accept" => "application/json" })
61
+ raise "HTTP #{res.code} fetching #{url}" unless res.is_a?(Net::HTTPSuccess)
62
+ JSON.parse(res.body)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ module Shipeasy
2
+ module I18n
3
+ # Auto-mounts ViewHelpers into ActionView when the gem is loaded inside
4
+ # a Rails app. Skipped silently when ::Rails isn't defined (plain Ruby
5
+ # consumers of the SDK never see the i18n surface).
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "shipeasy.i18n.view_helpers" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ include Shipeasy::I18n::ViewHelpers
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,49 @@
1
+ module Shipeasy
2
+ module I18n
3
+ module ViewHelpers
4
+ def i18n_head_tags(profile: nil, chunk: nil)
5
+ safe_join([
6
+ i18n_inline_data(profile: profile, chunk: chunk),
7
+ i18n_script_tag,
8
+ ], "\n")
9
+ end
10
+
11
+ def i18n_inline_data(profile: nil, chunk: nil)
12
+ config = Shipeasy.config
13
+ label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
14
+ profile: profile || config.profile,
15
+ chunk: chunk || config.default_chunk,
16
+ )
17
+ return "".html_safe unless label_file
18
+
19
+ json_content = JSON.generate(label_file)
20
+ content_tag(:script, json_content.html_safe, id: "i18n-data", type: "application/json")
21
+ end
22
+
23
+ def i18n_script_tag(hide_until_ready: false)
24
+ config = Shipeasy.config
25
+ attrs = {
26
+ src: config.loader_url,
27
+ "data-key": config.public_key,
28
+ "data-profile": config.profile,
29
+ async: true,
30
+ }
31
+ attrs[:"data-hide-until-ready"] = "true" if hide_until_ready
32
+ tag(:script, attrs)
33
+ end
34
+
35
+ def i18n_t(key, variables = {}, profile: nil, chunk: nil)
36
+ config = Shipeasy.config
37
+ label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
38
+ profile: profile || config.profile,
39
+ chunk: chunk || config.default_chunk,
40
+ )
41
+ return key unless label_file && label_file["strings"]
42
+
43
+ value = label_file["strings"][key] || key
44
+ variables.each { |k, v| value = value.gsub("{{#{k}}}", v.to_s) }
45
+ value
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ require "securerandom"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Anonymous bucketing identity — the cross-SDK `__se_anon_id` cookie.
6
+ #
7
+ # Gates and experiments bucket a unit with murmur3(salt:unit). For a
8
+ # logged-out visitor the unit is a stable anonymous id carried in a single
9
+ # first-party cookie that EVERY Shipeasy SDK (server + browser) reads and
10
+ # writes, so a server render and the browser bucket a fractional rollout
11
+ # identically. The cookie name + format are frozen across every language;
12
+ # see experiment-platform/18-identity-bucketing.md.
13
+ module AnonId
14
+ COOKIE = "__se_anon_id".freeze
15
+ MAX_AGE = 31_536_000 # 1 year, in seconds
16
+
17
+ # The cookie value is client-controllable and feeds bucketing, so a
18
+ # tampered value is treated as absent and a fresh id is minted. UUIDs
19
+ # satisfy this charset.
20
+ VALID_RX = /\A[A-Za-z0-9_-]{1,64}\z/.freeze
21
+
22
+ THREAD_KEY = :shipeasy_anon_id
23
+
24
+ module_function
25
+
26
+ # A fresh opaque bucketing id (UUIDv4).
27
+ def mint
28
+ SecureRandom.uuid
29
+ end
30
+
31
+ def valid?(value)
32
+ value.is_a?(String) && VALID_RX.match?(value)
33
+ end
34
+
35
+ # The anon id RackMiddleware resolved for the current request, or nil when
36
+ # no middleware ran (e.g. a background job). FlagsClient falls back to this
37
+ # as the default anonymous_id, so evaluations need no per-call wiring.
38
+ def current
39
+ Thread.current[THREAD_KEY]
40
+ end
41
+
42
+ def current=(value)
43
+ Thread.current[THREAD_KEY] = value
44
+ end
45
+ end
46
+ end
47
+ end
@@ -66,7 +66,12 @@ module Shipeasy
66
66
  end
67
67
 
68
68
  uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
69
- return false unless uid
69
+ # No unit id (an unidentified request before any anon id is minted): a
70
+ # fully-rolled gate is on for everyone, so it can be answered without
71
+ # bucketing; a fractional rollout genuinely needs a stable unit, so deny
72
+ # until one exists. Rules above are still checked, so targeting wins.
73
+ # See experiment-platform/18-identity-bucketing.md.
74
+ return (gate["rolloutPct"] || gate[:rolloutPct] || 0) >= 10000 unless uid
70
75
 
71
76
  salt = gate["salt"] || gate[:salt]
72
77
  murmur3("#{salt}:#{uid}") % 10000 < (gate["rolloutPct"] || gate[:rolloutPct] || 0)
@@ -3,15 +3,30 @@ require "uri"
3
3
  require "json"
4
4
  require "thread"
5
5
  require_relative "eval"
6
+ require_relative "telemetry"
7
+ require_relative "anon_id"
6
8
 
7
9
  module Shipeasy
8
10
  module SDK
9
11
  class FlagsClient
10
12
  DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
11
13
 
12
- def initialize(api_key:, base_url: nil)
14
+ def initialize(api_key:, base_url: nil, env: "prod", disable_telemetry: false, telemetry_url: nil, test_mode: false)
13
15
  @api_key = api_key
14
16
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
17
+ # Test mode: no network, ever. init/init_once/track become no-ops and
18
+ # evaluation answers come purely from local overrides. Built via the
19
+ # FlagsClient.for_testing factory; see clear_overrides / override_*.
20
+ @test_mode = test_mode
21
+ # Per-evaluation usage telemetry. ON by default; pass
22
+ # disable_telemetry: true to opt out. See telemetry.rb.
23
+ @telemetry = Telemetry.new(
24
+ endpoint: telemetry_url || Telemetry::DEFAULT_TELEMETRY_URL,
25
+ sdk_key: api_key,
26
+ side: "server",
27
+ env: env,
28
+ disabled: disable_telemetry,
29
+ )
15
30
  @flags_blob = nil
16
31
  @exps_blob = nil
17
32
  @flags_etag = nil
@@ -20,42 +35,200 @@ module Shipeasy
20
35
  @mutex = Mutex.new
21
36
  @timer = nil
22
37
  @initialized = false
38
+ # Statsig-style local overrides. Keyed by resource name; an override,
39
+ # when present, short-circuits the corresponding getter. Usable on any
40
+ # client (test or live) for deterministic tests / local development.
41
+ @flag_overrides = {}
42
+ @config_overrides = {}
43
+ @exp_overrides = {}
44
+ # Change listeners — fired after a background poll returns NEW data
45
+ # (HTTP 200, not 304). Never fired in test/offline mode. Guarded by
46
+ # @mutex; see on_change / notify_change.
47
+ @change_listeners = []
48
+ end
49
+
50
+ # Build a no-network, immediately-usable client for tests. Telemetry is
51
+ # disabled, init/init_once/track are no-ops (never fetch), and no api_key
52
+ # is required. Seed it with override_flag / override_config /
53
+ # override_experiment, then call the normal getters.
54
+ def self.for_testing(env: "prod")
55
+ new(
56
+ api_key: "test",
57
+ env: env,
58
+ disable_telemetry: true,
59
+ test_mode: true,
60
+ )
61
+ end
62
+
63
+ # Build an offline client from a JSON snapshot file. The file holds the
64
+ # raw response bodies of the two SDK endpoints under "flags" and
65
+ # "experiments" keys:
66
+ #
67
+ # { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
68
+ #
69
+ # The returned client does ZERO network (reuses test_mode plumbing:
70
+ # init/init_once/track are no-ops, telemetry off) but, unlike a bare
71
+ # for_testing client, runs the REAL evaluator against the loaded blobs.
72
+ # Local overrides still apply on top. Handy for CI, air-gapped runs, and
73
+ # reproducing a production decision from a captured blob.
74
+ def self.from_file(path, env: "prod")
75
+ data = JSON.parse(File.read(path))
76
+ from_snapshot(flags: data["flags"], experiments: data["experiments"], env: env)
77
+ end
78
+
79
+ # Build an offline client directly from already-parsed blobs (same shape
80
+ # as the /sdk/flags and /sdk/experiments response bodies). See from_file.
81
+ def self.from_snapshot(flags: nil, experiments: nil, env: "prod")
82
+ client = for_testing(env: env)
83
+ client.send(:load_snapshot, flags, experiments)
84
+ client
23
85
  end
24
86
 
25
87
  def init
88
+ return if @test_mode
26
89
  fetch_all
27
90
  @initialized = true
28
91
  start_poll
29
92
  end
30
93
 
31
94
  def init_once
95
+ return if @test_mode
32
96
  return if @initialized
33
97
  fetch_all
34
98
  @initialized = true
35
99
  end
36
100
 
101
+ # --- Local overrides -------------------------------------------------
102
+ # An override wins over the fetched blob in the matching getter. Setters
103
+ # are mutex-guarded so they're safe to call alongside background polling
104
+ # on a live client.
105
+
106
+ def override_flag(name, value)
107
+ @mutex.synchronize { @flag_overrides[name.to_s] = (value ? true : false) }
108
+ self
109
+ end
110
+
111
+ def override_config(name, value)
112
+ @mutex.synchronize { @config_overrides[name.to_s] = value }
113
+ self
114
+ end
115
+
116
+ def override_experiment(name, group, params)
117
+ @mutex.synchronize do
118
+ @exp_overrides[name.to_s] = { group: group, params: params }
119
+ end
120
+ self
121
+ end
122
+
123
+ def clear_overrides
124
+ @mutex.synchronize do
125
+ @flag_overrides.clear
126
+ @config_overrides.clear
127
+ @exp_overrides.clear
128
+ end
129
+ self
130
+ end
131
+
132
+ # Register a listener fired after a background poll fetches NEW flag/config
133
+ # data (HTTP 200, not 304). Accepts either a block or any callable (an
134
+ # object responding to #call). Returns an unsubscribe proc — call it to
135
+ # remove the listener. Never fires in test/offline mode (no poll thread).
136
+ def on_change(callable = nil, &block)
137
+ listener = callable || block
138
+ raise ArgumentError, "on_change requires a block or callable" unless listener.respond_to?(:call)
139
+ @mutex.synchronize { @change_listeners << listener }
140
+ proc { @mutex.synchronize { @change_listeners.delete(listener) } }
141
+ end
142
+
37
143
  def destroy
38
144
  @timer&.kill
39
145
  @timer = nil
40
146
  end
41
147
 
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))
148
+ # Flag evaluation with the reason the value was reached. :value is the
149
+ # boolean result; :reason is one of the REASON_* constants below.
150
+ FlagDetail = Struct.new(:value, :reason, keyword_init: true)
151
+
152
+ # Reason constants for FlagDetail#reason / get_flag_detail.
153
+ REASON_CLIENT_NOT_READY = "CLIENT_NOT_READY" # no blob fetched/loaded yet
154
+ REASON_FLAG_NOT_FOUND = "FLAG_NOT_FOUND" # blob present, gate absent
155
+ REASON_OFF = "OFF" # gate present but disabled/killed
156
+ REASON_OVERRIDE = "OVERRIDE" # answered by a local override
157
+ REASON_RULE_MATCH = "RULE_MATCH" # evaluated true
158
+ REASON_DEFAULT = "DEFAULT" # evaluated false (rollout/rule)
159
+
160
+ # Evaluate a flag and return why. Telemetry ("gate" beacon) is emitted
161
+ # exactly once here (steps 2–5), never on the OVERRIDE short-circuit.
162
+ def get_flag_detail(name, user)
163
+ key = name.to_s
164
+
165
+ # 1. Override short-circuits before any telemetry (mirrors get_config).
166
+ override = @mutex.synchronize { @flag_overrides[key] if @flag_overrides.key?(key) }
167
+ return FlagDetail.new(value: override, reason: REASON_OVERRIDE) unless override.nil?
168
+
169
+ @telemetry.emit("gate", name)
170
+
171
+ flags_blob, gate = @mutex.synchronize { [@flags_blob, @flags_blob&.dig("gates", name)] }
172
+
173
+ # 2. Not initialized — no blob fetched or loaded yet.
174
+ return FlagDetail.new(value: false, reason: REASON_CLIENT_NOT_READY) if flags_blob.nil?
175
+
176
+ # 3. Blob present but this gate isn't in it.
177
+ return FlagDetail.new(value: false, reason: REASON_FLAG_NOT_FOUND) unless gate
178
+
179
+ # 4. Gate present but disabled (or killswitched) — eval_gate would also
180
+ # return false here, but the reason is OFF, not a rollout DEFAULT.
181
+ if Eval.enabled?(gate["killswitch"]) || !Eval.enabled?(gate["enabled"])
182
+ return FlagDetail.new(value: false, reason: REASON_OFF)
183
+ end
184
+
185
+ # 5. Run the canonical evaluator; reason follows the boolean result.
186
+ result = Eval.eval_gate(gate, with_anon_id(user))
187
+ FlagDetail.new(value: result, reason: result ? REASON_RULE_MATCH : REASON_DEFAULT)
188
+ end
189
+
190
+ def get_flag(name, user, default: false)
191
+ detail = get_flag_detail(name, user)
192
+ if detail.reason == REASON_CLIENT_NOT_READY || detail.reason == REASON_FLAG_NOT_FOUND
193
+ default
194
+ else
195
+ detail.value
196
+ end
46
197
  end
47
198
 
48
- def get_config(name, decode = nil)
199
+ def get_config(name, decode = nil, default: nil)
200
+ key = name.to_s
201
+ has_override, override = @mutex.synchronize do
202
+ [@config_overrides.key?(key), @config_overrides[key]]
203
+ end
204
+ if has_override
205
+ return decode ? decode.call(override) : override
206
+ end
207
+
208
+ @telemetry.emit("config", name)
49
209
  entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
50
- return nil unless entry
210
+ return default unless entry
51
211
  value = entry["value"]
52
212
  decode ? decode.call(value) : value
53
213
  end
54
214
 
55
215
  def get_experiment(name, user, default_params, decode = nil)
216
+ key = name.to_s
217
+ override = @mutex.synchronize { @exp_overrides[key] }
218
+ if override
219
+ params = override[:params]
220
+ params = decode.call(params) if decode
221
+ return Eval::ExperimentResult.new(
222
+ in_experiment: true,
223
+ group: override[:group],
224
+ params: params,
225
+ )
226
+ end
227
+
228
+ @telemetry.emit("experiment", name)
56
229
  flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
57
230
  exp = exps_blob&.dig("experiments", name)
58
- result = Eval.eval_experiment(exp, flags_blob, exps_blob, user.transform_keys(&:to_s))
231
+ result = Eval.eval_experiment(exp, flags_blob, exps_blob, with_anon_id(user))
59
232
  result.params ||= default_params
60
233
 
61
234
  if result.in_experiment && decode
@@ -75,6 +248,8 @@ module Shipeasy
75
248
  end
76
249
 
77
250
  def track(user_id, event_name, props = {})
251
+ return if @test_mode
252
+
78
253
  payload = JSON.generate({
79
254
  events: [{
80
255
  type: "metric",
@@ -94,6 +269,50 @@ module Shipeasy
94
269
 
95
270
  private
96
271
 
272
+ # Load a parsed snapshot into the local blobs and mark the client ready,
273
+ # without any network. Used by from_snapshot / from_file on a test_mode
274
+ # client so the real evaluator runs against captured data.
275
+ def load_snapshot(flags, experiments)
276
+ @mutex.synchronize do
277
+ @flags_blob = flags
278
+ @exps_blob = experiments
279
+ end
280
+ @initialized = true
281
+ self
282
+ end
283
+
284
+ # Fire each change listener, snapshotting the array under the mutex so a
285
+ # listener that unsubscribes mid-callback doesn't mutate the list we're
286
+ # iterating. Listener errors are isolated (warn, never propagate).
287
+ def notify_change
288
+ listeners = @mutex.synchronize { @change_listeners.dup }
289
+ listeners.each do |listener|
290
+ begin
291
+ listener.call
292
+ rescue => e
293
+ warn "[shipeasy] on_change listener raised: #{e.message}"
294
+ end
295
+ end
296
+ end
297
+
298
+ # Normalise the user hash to string keys and, when the caller passed no
299
+ # explicit unit, default anonymous_id to the request's __se_anon_id (set by
300
+ # RackMiddleware). Lets `get_flag("x", {})` bucket anonymous traffic with
301
+ # zero per-call wiring. A caller-supplied user_id/anonymous_id always wins.
302
+ def with_anon_id(user)
303
+ u = user.transform_keys(&:to_s)
304
+ has_unit = !blank?(u["user_id"]) || !blank?(u["anonymous_id"])
305
+ unless has_unit
306
+ anon = AnonId.current
307
+ u["anonymous_id"] = anon if anon
308
+ end
309
+ u
310
+ end
311
+
312
+ def blank?(v)
313
+ v.nil? || v == ""
314
+ end
315
+
97
316
  def start_poll
98
317
  @timer = Thread.new do
99
318
  loop do
@@ -130,6 +349,8 @@ module Shipeasy
130
349
  @flags_etag = etag if etag
131
350
  @flags_blob = blob
132
351
  end
352
+ # New data arrived (200, not the 304 returned above) — notify listeners.
353
+ notify_change
133
354
  interval
134
355
  end
135
356
 
@@ -0,0 +1,85 @@
1
+ require_relative "anon_id"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Rack middleware that mints the shared `__se_anon_id` bucketing cookie.
6
+ #
7
+ # For every request without a valid `__se_anon_id` cookie it mints a UUIDv4,
8
+ # exposes it for the duration of the request, and Set-Cookies it on the
9
+ # response. Once installed, gate/experiment evaluations with no explicit
10
+ # user_id / anonymous_id automatically bucket on the cookie id — anonymous
11
+ # visitors get stable, SSR/browser-consistent bucketing with zero per-call
12
+ # wiring.
13
+ #
14
+ # Rails apps get this automatically (a Railtie inserts it). For Sinatra /
15
+ # Hanami / bare Rack, add it yourself:
16
+ #
17
+ # use Shipeasy::SDK::RackMiddleware
18
+ #
19
+ # The resolved id is also stored in the Rack env under "shipeasy.anon_id"
20
+ # for callers that prefer to read it explicitly.
21
+ class RackMiddleware
22
+ ENV_KEY = "shipeasy.anon_id".freeze
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ id, minted = read_or_mint(env)
30
+ env[ENV_KEY] = id
31
+ AnonId.current = id
32
+ begin
33
+ status, headers, body = @app.call(env)
34
+ ensure
35
+ # Don't leak the id onto the next request handled by this thread.
36
+ AnonId.current = nil
37
+ end
38
+ set_cookie!(headers, id, env) if minted
39
+ [status, headers, body]
40
+ end
41
+
42
+ private
43
+
44
+ def read_or_mint(env)
45
+ raw = parse_cookies(env["HTTP_COOKIE"])[AnonId::COOKIE]
46
+ return [raw, false] if AnonId.valid?(raw)
47
+
48
+ [AnonId.mint, true]
49
+ end
50
+
51
+ def parse_cookies(header)
52
+ out = {}
53
+ return out unless header
54
+
55
+ header.split(/;\s*/).each do |pair|
56
+ k, v = pair.split("=", 2)
57
+ out[k] = v if k && v && !out.key?(k)
58
+ end
59
+ out
60
+ end
61
+
62
+ def set_cookie!(headers, id, env)
63
+ cookie = +"#{AnonId::COOKIE}=#{id}; Path=/; Max-Age=#{AnonId::MAX_AGE}; SameSite=Lax"
64
+ cookie << "; Secure" if https?(env)
65
+
66
+ # Append without clobbering any Set-Cookie the app already emitted, and
67
+ # match the existing header key's case (Rack 3 mandates lowercase).
68
+ key = headers.keys.find { |k| k.respond_to?(:casecmp) && k.casecmp("set-cookie").zero? } || "Set-Cookie"
69
+ existing = headers[key]
70
+ headers[key] =
71
+ case existing
72
+ when nil then cookie
73
+ when Array then existing + [cookie]
74
+ else "#{existing}\n#{cookie}"
75
+ end
76
+ end
77
+
78
+ def https?(env)
79
+ env["HTTPS"] == "on" ||
80
+ env["rack.url_scheme"] == "https" ||
81
+ env["HTTP_X_FORWARDED_PROTO"].to_s.split(",").first.to_s.strip.casecmp("https").zero?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "rack_middleware"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Auto-mounts RackMiddleware in a Rails app so anonymous bucketing works
6
+ # out of the box — no manual `config.middleware.use`. Loaded only when Rails
7
+ # is present (see lib/shipeasy-sdk.rb), so plain Ruby apps are unaffected.
8
+ class Railtie < ::Rails::Railtie
9
+ initializer "shipeasy.sdk.anon_id_middleware" do |app|
10
+ app.middleware.use Shipeasy::SDK::RackMiddleware
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,78 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "digest"
4
+ require "erb"
5
+ require "thread"
6
+
7
+ module Shipeasy
8
+ module SDK
9
+ # Per-evaluation usage telemetry. Fires one fire-and-forget HTTP beacon per
10
+ # evaluation so usage is counted by Cloudflare's native per-path analytics.
11
+ # Mirrors the contract in the TypeScript reference SDK and
12
+ # experiment-platform/15-usage-metering.md. The path carries sha256(api_key)
13
+ # -- never the raw key -- plus side/env, then feature/resource. A long-lived
14
+ # Ruby process emits reliably; the 2s dedup window bounds volume under loops.
15
+ class Telemetry
16
+ DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai"
17
+
18
+ def initialize(endpoint:, sdk_key:, side: "server", env: "prod", disabled: false, dedupe_ms: 2000)
19
+ endpoint = (endpoint || "").chomp("/")
20
+ @disabled = disabled || sdk_key.nil? || sdk_key.empty? || endpoint.empty?
21
+ @dedupe_ms = dedupe_ms
22
+ @last = {}
23
+ @mutex = Mutex.new
24
+ unless @disabled
25
+ key_hash = Digest::SHA256.hexdigest(sdk_key)
26
+ @prefix = "#{endpoint}/t/#{key_hash}/#{side}/#{enc(env)}"
27
+ end
28
+ end
29
+
30
+ # Best-effort usage beacon for one evaluation. Never blocks the caller
31
+ # (the thread owns the request) and never raises into evaluation.
32
+ def emit(feature, resource)
33
+ return if @disabled
34
+
35
+ if @dedupe_ms > 0
36
+ dedupe_key = "#{feature}/#{resource}"
37
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
38
+ duplicate = @mutex.synchronize do
39
+ last = @last[dedupe_key]
40
+ if last && (now - last) < @dedupe_ms
41
+ true
42
+ else
43
+ @last[dedupe_key] = now
44
+ false
45
+ end
46
+ end
47
+ return if duplicate
48
+ end
49
+
50
+ dispatch("#{@prefix}/#{feature}/#{enc(resource)}")
51
+ end
52
+
53
+ private
54
+
55
+ # Fire-and-forget HTTP GET on a background thread. Isolated as its own
56
+ # method so tests can intercept it without real network/timing.
57
+ def dispatch(url)
58
+ Thread.new do
59
+ begin
60
+ uri = URI(url)
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == "https"
63
+ http.open_timeout = 2
64
+ http.read_timeout = 2
65
+ http.get(uri.request_uri)
66
+ rescue StandardError
67
+ # telemetry must never affect the caller
68
+ end
69
+ end
70
+ end
71
+
72
+ # encodeURIComponent-equivalent: %20 for space, %2F for slash (NOT "+").
73
+ def enc(value)
74
+ ERB::Util.url_encode(value.to_s)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.0.0"
3
+ VERSION = "1.4.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -1,11 +1,27 @@
1
1
  require_relative "shipeasy/sdk/version"
2
+ require_relative "shipeasy/config"
2
3
  require_relative "shipeasy/sdk/murmur3"
3
4
  require_relative "shipeasy/sdk/eval"
4
5
  require_relative "shipeasy/sdk/flags_client"
6
+ require_relative "shipeasy/sdk/anon_id"
7
+ require_relative "shipeasy/sdk/rack_middleware"
8
+ require_relative "shipeasy/i18n/label_fetcher"
9
+
10
+ # Rails-only surface. Skipped on plain Ruby so the gem stays usable in
11
+ # non-Rails apps (Sinatra, Hanami, scripts) without pulling Rails in.
12
+ if defined?(::Rails)
13
+ require_relative "shipeasy/i18n/view_helpers"
14
+ require_relative "shipeasy/i18n/railtie"
15
+ # Auto-mounts RackMiddleware so anonymous bucketing works with no config.
16
+ require_relative "shipeasy/sdk/railtie"
17
+ end
5
18
 
6
19
  module Shipeasy
7
20
  module SDK
8
- def self.new_client(api_key:, base_url: nil)
21
+ # Convenience constructor. Reads api_key + base_url from the gem-wide
22
+ # config when omitted, so a single `Shipeasy.configure { … }` block at
23
+ # boot is enough.
24
+ def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
9
25
  FlagsClient.new(api_key: api_key, base_url: base_url)
10
26
  end
11
27
  end
metadata CHANGED
@@ -1,32 +1,93 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
- - ShipEasy
7
+ - Shipeasy, Inc.
8
+ autorequire:
8
9
  bindir: bin
9
10
  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.
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.71'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.71'
55
+ description: Server SDK for Shipeasy. Polls /sdk/flags and /sdk/experiments, evaluates
56
+ gates and experiments locally, forwards exposures + metrics to /collect, and (when
57
+ loaded inside Rails) auto-mounts i18n_head_tags / i18n_inline_data / i18n_script_tag
58
+ / i18n_t view helpers for the Shipeasy string-manager CDN.
14
59
  email:
15
- - sdk@shipeasy.dev
60
+ - sdk@shipeasy.ai
16
61
  executables: []
17
62
  extensions: []
18
63
  extra_rdoc_files: []
19
64
  files:
65
+ - LICENSE
20
66
  - README.md
21
67
  - lib/shipeasy-sdk.rb
68
+ - lib/shipeasy/config.rb
69
+ - lib/shipeasy/i18n/label_fetcher.rb
70
+ - lib/shipeasy/i18n/railtie.rb
71
+ - lib/shipeasy/i18n/view_helpers.rb
72
+ - lib/shipeasy/sdk/anon_id.rb
22
73
  - lib/shipeasy/sdk/eval.rb
23
74
  - lib/shipeasy/sdk/flags_client.rb
24
75
  - lib/shipeasy/sdk/murmur3.rb
76
+ - lib/shipeasy/sdk/rack_middleware.rb
77
+ - lib/shipeasy/sdk/railtie.rb
78
+ - lib/shipeasy/sdk/telemetry.rb
25
79
  - lib/shipeasy/sdk/version.rb
26
- homepage: https://github.com/shipeasy/sdk-ruby
80
+ homepage: https://github.com/shipeasy-ai/sdk-ruby
27
81
  licenses:
28
- - MIT
29
- metadata: {}
82
+ - Nonstandard
83
+ metadata:
84
+ homepage_uri: https://github.com/shipeasy-ai/sdk-ruby
85
+ source_code_uri: https://github.com/shipeasy-ai/sdk-ruby
86
+ bug_tracker_uri: https://github.com/shipeasy-ai/sdk-ruby/issues
87
+ documentation_uri: https://docs.shipeasy.ai
88
+ license_file: LICENSE
89
+ rubygems_mfa_required: 'true'
90
+ post_install_message:
30
91
  rdoc_options: []
31
92
  require_paths:
32
93
  - lib
@@ -34,14 +95,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
34
95
  requirements:
35
96
  - - ">="
36
97
  - !ruby/object:Gem::Version
37
- version: 2.7.0
98
+ version: '3.0'
38
99
  required_rubygems_version: !ruby/object:Gem::Requirement
39
100
  requirements:
40
101
  - - ">="
41
102
  - !ruby/object:Gem::Version
42
103
  version: '0'
43
104
  requirements: []
44
- rubygems_version: 4.0.6
105
+ rubygems_version: 3.5.22
106
+ signing_key:
45
107
  specification_version: 4
46
- summary: ShipEasy feature flag and experimentation SDK for Ruby
108
+ summary: Shipeasy feature gates, runtime configs, experiments, metrics, and i18n helpers
109
+ — Ruby gem.
47
110
  test_files: []