shipeasy-sdk 1.7.0 → 2.1.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: afdce684e3c6a83b220b7dfaa1d7db249284e3bb4c4ebfe3c9b27c54da0d801b
4
- data.tar.gz: ecdfdf8aed19f1b5f18fe874ce5b13064bb61abe7bf7f1c47a76b08d9ab99bce
3
+ metadata.gz: 9f3b0db78c3e6319bafac4913bb823b3dbc24a2e5f7166b1fe9a820f5588fadc
4
+ data.tar.gz: f232c0e140c5891bfd19112e8cea8877b42159806c5aba46185f54bd52b8d3ec
5
5
  SHA512:
6
- metadata.gz: e6a5470268bc584eaa2dbc8c89f722a22633d0bd6f896aaa1d73db74090b56667c87733c614b8a49bdeda88dab043740509153503943837a69be20a03dc315b7
7
- data.tar.gz: 55289a4adf0559f41eca2d1f6bca03b53a19da9b3f00f4109dc4476ed0c5cbaaed453dbaf3f7c0ebcc1405f0d73a55fe702e02859c9a809d525e92b294e275ba
6
+ metadata.gz: 546b88ba03ff9944de78b6982c142b4beba2325dd2b07702bf83518ce7b29778ca2f963c69b3fdfa30fab4a65ff3327ef700666a648e07d4fc2f70d2f57fd03a
7
+ data.tar.gz: eb288754985044ef4d2c3094ce6eb78172eb107394e66a191d6a3740afe167b9b5621a4a4e3ee618d51d62aa1f1f7ce3d1daa873243996cbd4037e62e4ba390f
data/README.md CHANGED
@@ -1,10 +1,40 @@
1
+ <!--
2
+ This file is GENERATED by scripts/gen_readme.rb from docs/.
3
+ Do NOT edit by hand — edit the docs, then run: ruby scripts/gen_readme.rb
4
+ -->
5
+
1
6
  # shipeasy-sdk (Ruby)
2
7
 
3
- Ruby gem for the [Shipeasy](https://shipeasy.ai) hosted service. Server-side
4
- gate evaluation, runtime configs, experiments, and metric ingestion.
8
+ [![Tests](https://github.com/shipeasy-ai/sdk-ruby/actions/workflows/test.yml/badge.svg)](https://github.com/shipeasy-ai/sdk-ruby/actions/workflows/test.yml)
9
+ [![Gem](https://img.shields.io/gem/v/shipeasy-sdk.svg)](https://rubygems.org/gems/shipeasy-sdk)
10
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.0-CC342D.svg)](https://www.ruby-lang.org/)
11
+
12
+ Server SDK for [Shipeasy](https://shipeasy.ai) — **feature flags, dynamic
13
+ configs, kill switches, A/B experiments, and metric tracking**, with Rails i18n
14
+ view helpers. Server-key only; never embed in a browser.
15
+
16
+ > 📚 **Full documentation:** **<https://shipeasy-ai.github.io/sdk-ruby/>** — also browsable under
17
+ > [`docs/`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs). This README is generated from those docs.
5
18
 
6
19
  > Source-available under the [Shipeasy-SAL 1.0](./LICENSE).
7
20
 
21
+ ## 🤖 Using an AI agent?
22
+
23
+ This SDK ships an installable **agent skill** — a copy-paste-ready guide to
24
+ `Shipeasy.configure` + `Client.new(user)`, testing, experiments, error
25
+ reporting, and more, with links the agent can pull for deeper docs:
26
+
27
+ - **Skill:** [`docs/skill/SKILL.md`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/skill/SKILL.md) · raw:
28
+ `https://shipeasy-ai.github.io/sdk-ruby/skill/SKILL.md`
29
+ - **Install it** (ships with the gem — no network):
30
+ `shipeasy-skill install` → `.claude/skills/shipeasy-ruby/SKILL.md`
31
+ (or via the Shipeasy CLI: `shipeasy docs skill --sdk ruby --install`)
32
+
33
+ **Humans:** you can copy that skill straight into your own project's agent skills
34
+ directory (e.g. `.claude/skills/shipeasy-ruby/SKILL.md`) so your coding agent
35
+ always uses the correct Shipeasy patterns. Every doc page and snippet is also
36
+ fetchable by URL — start from the manifest at `https://shipeasy-ai.github.io/sdk-ruby/manifest.json`.
37
+
8
38
  ## Install
9
39
 
10
40
  ```ruby
@@ -12,297 +42,86 @@ gate evaluation, runtime configs, experiments, and metric ingestion.
12
42
  gem "shipeasy-sdk"
13
43
  ```
14
44
 
15
- ## Quickstart (Rails)
45
+ Requires Ruby 3.0+. Per-framework setup (Rails / Sinatra / serverless) and the
46
+ anon-id middleware are on the [Installation](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/installation.md) page.
16
47
 
17
- `config/initializers/shipeasy.rb` is all you need:
48
+ ## Quickstart — `configure` once, then `Client.new(user)` per request
18
49
 
19
50
  ```ruby
51
+ # boot (config/initializers/shipeasy.rb)
20
52
  Shipeasy.configure do |c|
21
53
  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"
54
+ c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
24
55
  end
25
- ```
26
-
27
- Anywhere in your app:
28
-
29
- ```ruby
30
- user = { user_id: current_user.id, plan: current_user.plan }
31
-
32
- if Shipeasy.flags.get_flag("new_checkout", user)
33
- # ship it
34
- end
35
-
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
- ## Server-side rendering (SSR)
119
-
120
- Emit the request's evaluated flags as a declarative `<script>` tag so the
121
- browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
122
- in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
123
- hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
124
- browser buckets identically to the server.
125
-
126
- ```ruby
127
- user = { "user_id" => "u_123" }
128
-
129
- # Two tags for the document <head>. The PUBLIC client key (not the server
130
- # key) goes on the i18n loader tag.
131
- head = client.bootstrap_script_tag(user, anon_id: anon_id) +
132
- client.i18n_script_tag(client_key, profile: "en:prod")
133
-
134
- # …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
135
- boot = client.evaluate(user)
136
- ```
137
-
138
- `bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
139
- `https://cdn.shipeasy.ai`). In **Rails**, the existing
140
- `Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
141
- loader tag from your app config.
142
-
143
- ## Default values
144
-
145
- `get_flag` and `get_config` take an optional `default:` returned **only when the
146
- value cannot be resolved** — never when a flag genuinely evaluates to `false`.
147
-
148
- ```ruby
149
- # Flag: default is returned only when the client isn't ready yet (no blob
150
- # fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
151
- # or outside its rollout) returns false, NOT the default.
152
- Shipeasy.flags.get_flag("new_checkout", user, default: true)
153
-
154
- # Config: default is returned when the config key is absent. A decode proc still
155
- # runs on a present value.
156
- Shipeasy.flags.get_config("button_color", default: "blue")
157
- Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)
158
- ```
159
-
160
- ## Evaluation detail
161
-
162
- `get_flag_detail(name, user)` returns the boolean **and the reason** it was
163
- reached, as a `FlagDetail` struct (`.value`, `.reason`). `get_flag` is built on
164
- top of it. The reason is one of the `REASON_*` constants:
165
-
166
- | Reason | Meaning |
167
- | ------------------ | --------------------------------------------------- |
168
- | `OVERRIDE` | answered by a local `override_flag` (no telemetry) |
169
- | `CLIENT_NOT_READY` | no flag blob fetched/loaded yet |
170
- | `FLAG_NOT_FOUND` | blob present, but this gate isn't in it |
171
- | `OFF` | gate present but disabled or killswitched |
172
- | `RULE_MATCH` | evaluated to `true` |
173
- | `DEFAULT` | evaluated to `false` (rollout/rule) |
174
-
175
- ```ruby
176
- detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
177
- detail.value # => true / false
178
- detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...
179
- ```
180
-
181
- The `gate` usage beacon fires exactly once per `get_flag_detail` call (never on
182
- the `OVERRIDE` short-circuit).
183
56
 
184
- ## Change listeners
57
+ # per request — construct once per callsite (cheap; binds the user)
58
+ flags = Shipeasy::Client.new(current_user)
185
59
 
186
- `on_change` registers a callback fired after a background poll fetches **new**
187
- flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
188
- returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
189
- no poll thread). A raising listener is isolated and logged, not propagated.
190
-
191
- ```ruby
192
- unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
193
- # ... later
194
- unsubscribe.call
195
- ```
196
-
197
- ## Offline snapshot
198
-
199
- For CI, air-gapped runs, or reproducing a production decision from a captured
200
- blob, build a **no-network** client that still runs the real evaluator against a
201
- snapshot. The snapshot JSON holds the raw response bodies of the two SDK
202
- endpoints:
203
-
204
- ```json
205
- { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
60
+ flags.get_flag("new_checkout") # NO user arg bound at construction
61
+ flags.get_config("button_color")
62
+ result = flags.get_experiment("checkout_cta", { label: "Buy" })
63
+ flags.log_exposure("checkout_cta") # at the decision point
64
+ flags.track("purchase", { revenue: 49 }) # on conversion
65
+ flags.get_killswitch("payments")
206
66
  ```
207
67
 
208
- ```ruby
209
- client = Shipeasy::SDK::FlagsClient.from_file("snapshot.json")
210
- # or, from already-parsed blobs:
211
- client = Shipeasy::SDK::FlagsClient.from_snapshot(flags: flags_body, experiments: exps_body)
212
-
213
- client.get_flag("new_checkout", user) # real evaluation, no network
214
- client.get_experiment("checkout_cta", user, {})
215
- ```
68
+ Constructing `Shipeasy::Client.new(user)` before `Shipeasy.configure` raises
69
+ `Shipeasy::Error`.
216
70
 
217
- `init` / `init_once` / `track` are no-ops and telemetry is off (it reuses the
218
- `for_testing` plumbing). Local `override_*` setters still apply on top of the
219
- snapshot.
220
-
221
- ## Evaluation details
71
+ ## Documentation
222
72
 
223
- - **Gates** rules matched in order; rollout bucket =
224
- `murmur3("#{salt}:#{uid}") % 10000 < rollout_pct`.
225
- - **Experiments** `status == "running"`, optional targeting gate,
226
- universe holdout range, allocation bucket, then group assignment by
227
- weight.
228
- - **MurmurHash3** — pure-Ruby x86_32 variant, seed 0.
229
- - **ETag caching** each poll sends `If-None-Match`; a 304 skips the
230
- JSON parse.
231
- - **Poll interval** defaults to 30 s; overridden by the
232
- `X-Poll-Interval` header from the flags endpoint.
73
+ | Page | What |
74
+ | --- | --- |
75
+ | [Overview](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/overview.md) | The `Shipeasy.configure` + `Client.new(user)` model. |
76
+ | [Installation](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/installation.md) | Install, frameworks (Rails / Sinatra / serverless), `configure` wiring. |
77
+ | [Configuration](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configuration.md) | Keys, `attributes`, one-shot vs poll, every option. |
78
+ | [Feature flags](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/flags.md) | `get_flag`, `get_flag_detail`, defaults. |
79
+ | [Dynamic configs](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configs.md) | `get_config`, typed decode, defaults. |
80
+ | [Kill switches](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/killswitches.md) | `get_killswitch`, named switches. |
81
+ | [Experiments](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/experiments.md) | `get_experiment`, `log_exposure`, `track`. |
82
+ | [Internationalization](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/i18n.md) | Rails view helpers + the SSR loader tag. |
83
+ | [Error reporting](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/error-reporting.md) | `see()` structured error reporting. |
84
+ | [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) | `configure_for_testing` / `configure_for_offline`, overrides. |
85
+ | [OpenFeature](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/openfeature.md) | `Shipeasy::OpenFeature::Provider` (OpenFeature server provider). |
86
+ | [Advanced](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/advanced.md) | Anon-id middleware, private attributes, sticky bucketing, SSR. |
87
+
88
+ Copy-paste snippets live under [`docs/snippets/`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/snippets)
89
+ (release · metrics · i18n · ops); an installable agent skill is at
90
+ [`docs/skill/SKILL.md`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/skill/SKILL.md).
233
91
 
234
92
  ## Testing
235
93
 
236
- For unit/integration tests you want a client that does **zero network** and
237
- returns exactly the values you seed — no api_key, no fetch, no poll thread, no
238
- telemetry, no metric ingestion. Build one with `FlagsClient.for_testing` and
239
- seed each entity with the `override_*` setters (Statsig-style local overrides).
240
- An override always wins over the fetched blob, so the getters answer
241
- deterministically:
94
+ Use **`Shipeasy.configure_for_testing`** the test-mode sibling of [`Shipeasy.configure`](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/configuration.md). It does **zero network**, needs no api key, and seeds the values your code under test should see via override arguments. Then read through the ordinary `Shipeasy::Client.new(user)` — the *same* call your production code uses.
242
95
 
243
96
  ```ruby
244
97
  require "shipeasy-sdk"
245
98
 
246
- client = Shipeasy::SDK::FlagsClient.for_testing
247
- # init / init_once are no-ops here — nothing is ever fetched.
248
-
249
- # Flags (boolean)
250
- client.override_flag("new_checkout", true)
251
- client.get_flag("new_checkout", { user_id: "u_1" }) # => true
99
+ Shipeasy.configure_for_testing(
100
+ flags: { "new_checkout" => true },
101
+ configs: { "billing_copy" => { "title" => "Welcome" } },
102
+ experiments: { "checkout_button" => ["treatment", { "color" => "green" }] },
103
+ )
252
104
 
253
- # Configs (any value; an optional decode proc still runs)
254
- client.override_config("button_color", "blue")
255
- client.get_config("button_color") # => "blue"
256
- client.override_config("limits", { "max" => 10 })
257
- client.get_config("limits", ->(v) { v["max"] }) # => 10
105
+ # construct once per callsite (cheap; binds the user)
106
+ client = Shipeasy::Client.new({ "user_id" => "u_123" })
258
107
 
259
- # Experiments — returns an in-experiment Eval::ExperimentResult
260
- client.override_experiment("checkout_cta", "treatment", { label: "Buy now" })
261
- r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
262
- r.in_experiment # => true
263
- r.group # => "treatment"
264
- r.params # => { label: "Buy now" }
108
+ client.get_flag("new_checkout") # => true
109
+ client.get_config("billing_copy") # => { "title" => "Welcome" }
265
110
 
266
- # track is a no-op (no thread, no network) assert call counts without stubbing.
267
- client.track("u_1", "checkout_completed", { revenue: 49.99 }) # => nil
111
+ result = client.get_experiment("checkout_button", { "color" => "blue" })
112
+ result.in_experiment # => true
113
+ result.group # => "treatment"
114
+ result.params # => { "color" => "green" }
268
115
 
269
- # Reset between examples
270
- client.clear_overrides
116
+ # track / log_exposure are no-ops in test mode — safe to call, send nothing
117
+ client.track("purchase", { amount: 49 })
271
118
  ```
272
119
 
273
- The same `override_flag` / `override_config` / `override_experiment` /
274
- `clear_overrides` setters also work on a **normal** live client (built with
275
- `FlagsClient.new(...)`), so you can pin one value in local development while the
276
- rest comes from the fetched blob.
277
-
278
- ### Rails singleton
279
-
280
- `Shipeasy.flags` is a process-wide singleton that fetches over the network, so
281
- in tests prefer a `for_testing` client. If a code path reaches through
282
- `Shipeasy.flags` directly, stub the singleton to the test client in your test
283
- setup:
284
-
285
- ```ruby
286
- # RSpec
287
- before do
288
- test_client = Shipeasy::SDK::FlagsClient.for_testing
289
- test_client.override_flag("new_checkout", true)
290
- allow(Shipeasy).to receive(:flags).and_return(test_client)
291
- end
292
- ```
293
-
294
- ## Configuration
295
-
296
- | Parameter | Default | Description |
297
- | ---------- | ------------------------- | ----------------------------------- |
298
- | `api_key` | (required) | SDK key from the Shipeasy dashboard |
299
- | `base_url` | `https://cdn.shipeasy.ai` | Override for local dev / staging |
300
-
301
- ## Documentation
302
-
303
- [docs.shipeasy.ai](https://docs.shipeasy.ai)
120
+ More the on-the-spot override helpers and a working example
121
+ `shipeasy-snapshot.json` on the [Testing](https://github.com/shipeasy-ai/sdk-ruby/blob/main/docs/pages/testing.md) page.
304
122
 
305
123
  ## License
306
124
 
307
- [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use,
308
- permitted as a Shipeasy client.
125
+ [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use, permitted
126
+ as a Shipeasy client. Evaluation is tested against the cross-language
127
+ MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Opt-in installer for the bundled Shipeasy agent skill. See
5
+ # lib/shipeasy/sdk/skill.rb.
6
+ require "shipeasy/sdk/skill"
7
+
8
+ exit Shipeasy::SDK::Skill.main(ARGV)
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: shipeasy-ruby
3
+ description: Use Shipeasy (feature flags, configs, kill switches, A/B experiments, i18n) from Ruby. Covers Shipeasy.configure + Client.new(user), get_flag/get_config/get_experiment/get_killswitch, track, testing, OpenFeature.
4
+ ---
5
+
6
+ # Shipeasy Ruby SDK
7
+
8
+ Server-side Ruby gem (`shipeasy-sdk`) for Shipeasy: feature gates, dynamic
9
+ configs, kill switches, A/B experiments, metrics, `see()` error reporting, and
10
+ Rails i18n view helpers. Server-key only — never embed in a browser. Ruby 3.0+.
11
+
12
+ Two things only: **`Shipeasy.configure`** once at boot, then
13
+ **`Shipeasy::Client.new(user)`** per request.
14
+
15
+ > **Pulling deeper docs.** Each section below links its full reference page and
16
+ > copy-paste snippets — fetch any of them as raw Markdown when you need more than
17
+ > this summary. Discover the whole tree from the manifest:
18
+ > `https://shipeasy-ai.github.io/sdk-ruby/manifest.json` (lists every
19
+ > `pages/<key>.md` and `snippets/<group>/<leaf>.md`). All URLs below are
20
+ > `https://shipeasy-ai.github.io/sdk-ruby/…`.
21
+
22
+ ## Configure once (boot)
23
+
24
+ ```ruby
25
+ # config/initializers/shipeasy.rb
26
+ Shipeasy.configure do |c|
27
+ c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
28
+ # Optional: map your user object → the attribute hash (runs once in Client.new).
29
+ c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
30
+ # i18n only (public client key + profile):
31
+ c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY")
32
+ c.profile = "default"
33
+ end
34
+ ```
35
+
36
+ Omit `c.attributes` if your user object is already the attribute hash. For a
37
+ long-running server set `c.poll = true` to keep the blob fresh in the background;
38
+ the default (one-shot fetch, no thread) is serverless-friendly.
39
+
40
+ → More: `pages/installation.md` (per-framework setup), `pages/configuration.md`
41
+ (every option).
42
+
43
+ ## Evaluate (bound `Client.new(user)` — NO user arg)
44
+
45
+ Bind the user once per request, then call without re-passing it — `track` and
46
+ `log_exposure` are on the bound client too, so experiments are end-to-end here:
47
+
48
+ ```ruby
49
+ flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
50
+
51
+ flags.get_flag("new_checkout") # bool; default: only when unresolved
52
+ flags.get_config("button_color", default: "blue")
53
+ flags.get_killswitch("payments") # true = killed; optional switch_key
54
+ result = flags.get_experiment("checkout_cta", { label: "Buy now" })
55
+ # result.in_experiment / result.group / result.params
56
+
57
+ flags.log_exposure("checkout_cta") # at the decision point
58
+ flags.track("purchase", { revenue: 49 }) # conversion / metric event
59
+ ```
60
+
61
+ `get_flag_detail` returns a `FlagDetail` (`.value`, `.reason`: `RULE_MATCH`,
62
+ `DEFAULT`, `OFF`, `OVERRIDE`, `FLAG_NOT_FOUND`, `CLIENT_NOT_READY`).
63
+
64
+ → More: pages `pages/flags.md` · `pages/configs.md` · `pages/killswitches.md`
65
+ (incl. named switches) · `pages/experiments.md`. Snippets
66
+ `snippets/release/{flags,configs,killswitches,experiments}.md` and
67
+ `snippets/metrics/track.md`.
68
+
69
+ ## Testing (no network)
70
+
71
+ Use the `configure` siblings — seed overrides, read through the same `Client`:
72
+
73
+ ```ruby
74
+ Shipeasy.configure_for_testing(
75
+ flags: { "new_checkout" => true },
76
+ configs: { "billing_copy" => { "title" => "Welcome" } },
77
+ experiments: { "checkout_button" => ["treatment", { "color" => "green" }] },
78
+ )
79
+ Shipeasy::Client.new({ "user_id" => "u_123" }).get_flag("new_checkout") # => true
80
+
81
+ # flip a value on the spot, mid-test:
82
+ Shipeasy.override_flag("new_checkout", false)
83
+ Shipeasy.clear_overrides
84
+ ```
85
+
86
+ Offline (real rules from a snapshot / file):
87
+
88
+ ```ruby
89
+ Shipeasy.configure_for_offline(path: "snapshot.json")
90
+ # or snapshot: { "flags" => {...}, "experiments" => {...} }, plus optional overrides
91
+ ```
92
+
93
+ → More: `pages/testing.md` (override helpers + a working example
94
+ `shipeasy-snapshot.json`).
95
+
96
+ ## OpenFeature
97
+
98
+ ```ruby
99
+ require "open_feature/sdk" # optional dep: gem "openfeature-sdk" (Ruby ≥ 3.4)
100
+ require "shipeasy/sdk/openfeature"
101
+
102
+ Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
103
+ OpenFeature::SDK.configure { |c| c.set_provider(Shipeasy::OpenFeature::Provider.new) } # uses the global
104
+ ```
105
+
106
+ Boolean → gate; string/number/object → config.
107
+
108
+ → More: `pages/openfeature.md` (reason mapping, type routing).
109
+
110
+ ## Error reporting — see()
111
+
112
+ ```ruby
113
+ begin
114
+ charge_card(order)
115
+ rescue => e
116
+ Shipeasy.see(e).causes_the("checkout").to("use the backup processor")
117
+ end
118
+ ```
119
+
120
+ `Shipeasy.see_violation(name)` for non-exception problems;
121
+ `Shipeasy.control_flow_exception(e).because(...)` marks expected control flow
122
+ (reports nothing).
123
+
124
+ → More: `pages/error-reporting.md` · snippets `snippets/ops/see.md`
125
+ (`.extras`, violations, control-flow exceptions).
126
+
127
+ ## i18n (Rails)
128
+
129
+ ```erb
130
+ <%= i18n_head_tags %>
131
+ <h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
132
+ ```
133
+
134
+ Outside Rails: `Shipeasy.i18n_script_tag(client_key, profile: "en:prod")` emits
135
+ the loader tag (public client key).
136
+
137
+ → More: `pages/i18n.md` · snippets `snippets/i18n/{setup,render}.md`.
138
+
139
+ ## Other surfaces
140
+
141
+ - Anon bucketing: `Shipeasy::SDK::RackMiddleware` mints the shared `__se_anon_id`
142
+ cookie (Rails Railtie auto-mounts it); anonymous `get_flag` then just works.
143
+ - `c.private_attributes = ["email"]` strips keys from outbound events.
144
+ - `c.sticky_store = Shipeasy::SDK::InMemoryStickyStore.new` pins experiment assignment.
145
+ - SSR: `Shipeasy.bootstrap_script_tag(user)` + `Shipeasy.i18n_script_tag(client_key, "en:prod")`.
146
+ - `Shipeasy.on_change { ... }` (requires `c.poll = true`) fires after a poll fetches new data.
147
+
148
+ → More: `pages/advanced.md`.
@@ -0,0 +1,71 @@
1
+ module Shipeasy
2
+ # A lightweight, user-bound evaluation handle. Construct one per user/request
3
+ # via its real constructor:
4
+ #
5
+ # flags = Shipeasy::Client.new(current_user)
6
+ # flags.get_flag("new_checkout") # NO user arg — bound at construction
7
+ # flags.get_experiment("price_test", { price: 9 })
8
+ #
9
+ # It is cheap: it delegates every evaluation to the single global engine built
10
+ # by `Shipeasy.configure { … }`. It does NOT open its own HTTP connection,
11
+ # fetch, or start a poll timer.
12
+ #
13
+ # The configured `attributes` transform (see Shipeasy::Configuration#attributes)
14
+ # runs ONCE here, in the constructor, against the raw user object you pass.
15
+ # The resulting attribute hash is then enriched with the request-scoped
16
+ # anonymous_id (when you supplied neither user_id nor anonymous_id) and bound,
17
+ # so every getter reads the same bag.
18
+ #
19
+ # Raises if constructed before `Shipeasy.configure` registered an engine.
20
+ class Client
21
+ # The resolved attribute hash this handle evaluates against.
22
+ attr_reader :attributes
23
+
24
+ def initialize(user)
25
+ engine = Shipeasy.engine
26
+ if engine.nil?
27
+ raise Error, "Shipeasy::Client.new(user) called before Shipeasy.configure " \
28
+ "{ |c| c.api_key = … }. Call Shipeasy.configure once at app boot."
29
+ end
30
+ @engine = engine
31
+ # Run the configured attributes transform (default identity), then apply
32
+ # the existing anon-id merge exactly as the per-call engine path does.
33
+ mapped = Shipeasy.attributes_transform.call(user)
34
+ @attributes = engine.bind_attributes(mapped)
35
+ end
36
+
37
+ def get_flag(name, default: false)
38
+ @engine.get_flag(name, @attributes, default: default)
39
+ end
40
+
41
+ def get_flag_detail(name)
42
+ @engine.get_flag_detail(name, @attributes)
43
+ end
44
+
45
+ # Configs are not user-scoped, but exposed here for one-stop ergonomics.
46
+ def get_config(name, decode = nil, default: nil)
47
+ @engine.get_config(name, decode, default: default)
48
+ end
49
+
50
+ def get_experiment(name, default_params, decode = nil)
51
+ @engine.get_experiment(name, @attributes, default_params, decode)
52
+ end
53
+
54
+ # Killswitches are not user-scoped; forwarded straight to the engine.
55
+ def get_killswitch(name, switch_key = nil)
56
+ @engine.get_killswitch(name, switch_key)
57
+ end
58
+
59
+ def track(event_name, props = {})
60
+ id = @attributes["user_id"] || @attributes["anonymous_id"]
61
+ @engine.track(id, event_name, props)
62
+ end
63
+
64
+ def log_exposure(experiment_name)
65
+ @engine.log_exposure(@attributes, experiment_name)
66
+ end
67
+ end
68
+
69
+ # Raised by Shipeasy::Client when constructed before Shipeasy.configure.
70
+ class Error < StandardError; end
71
+ end