shipeasy-sdk 2.0.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 +4 -4
- data/README.md +86 -294
- data/bin/shipeasy-skill +8 -0
- data/docs/skill/SKILL.md +148 -0
- data/lib/shipeasy/client.rb +9 -0
- data/lib/shipeasy/config.rb +192 -7
- data/lib/shipeasy/engine.rb +16 -8
- data/lib/shipeasy/sdk/openfeature.rb +33 -6
- data/lib/shipeasy/sdk/skill.rb +76 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f3b0db78c3e6319bafac4913bb823b3dbc24a2e5f7166b1fe9a820f5588fadc
|
|
4
|
+
data.tar.gz: f232c0e140c5891bfd19112e8cea8877b42159806c5aba46185f54bd52b8d3ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
|
|
8
|
+
[](https://github.com/shipeasy-ai/sdk-ruby/actions/workflows/test.yml)
|
|
9
|
+
[](https://rubygems.org/gems/shipeasy-sdk)
|
|
10
|
+
[](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,324 +42,86 @@ gate evaluation, runtime configs, experiments, and metric ingestion.
|
|
|
12
42
|
gem "shipeasy-sdk"
|
|
13
43
|
```
|
|
14
44
|
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
`Shipeasy::Client`** per request via its constructor.
|
|
19
|
-
|
|
20
|
-
`config/initializers/shipeasy.rb`:
|
|
48
|
+
## Quickstart — `configure` once, then `Client.new(user)` per request
|
|
21
49
|
|
|
22
50
|
```ruby
|
|
51
|
+
# boot (config/initializers/shipeasy.rb)
|
|
23
52
|
Shipeasy.configure do |c|
|
|
24
53
|
c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
|
|
25
|
-
|
|
26
|
-
# Optional: map YOUR user object → the Shipeasy attribute hash. Runs once,
|
|
27
|
-
# in the Shipeasy::Client constructor. Omit it and the object you pass to
|
|
28
|
-
# Shipeasy::Client.new IS the attribute hash (identity).
|
|
29
54
|
c.attributes = ->(u) { { "user_id" => u.id, "plan" => u.plan } }
|
|
30
|
-
|
|
31
|
-
c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
|
|
32
|
-
c.profile = "default"
|
|
33
|
-
end
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
`configure` builds the single global engine for you and kicks off a one-shot
|
|
37
|
-
fetch (fire-and-forget). Anywhere in your app, construct a bound client and call
|
|
38
|
-
the getters with **no user argument** — the user is bound at construction:
|
|
39
|
-
|
|
40
|
-
```ruby
|
|
41
|
-
flags = Shipeasy::Client.new(current_user) # runs the attributes transform once
|
|
42
|
-
|
|
43
|
-
if flags.get_flag("new_checkout") # NO user arg
|
|
44
|
-
# ship it
|
|
45
55
|
end
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
panic = flags.get_killswitch("payments")
|
|
50
|
-
```
|
|
57
|
+
# per request — construct once per callsite (cheap; binds the user)
|
|
58
|
+
flags = Shipeasy::Client.new(current_user)
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
```ruby
|
|
60
|
-
Shipeasy.engine.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
|
|
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")
|
|
61
66
|
```
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
> now the lightweight user-bound handle. The legacy `Shipeasy.flags.get_flag(name, user)`
|
|
66
|
-
> singleton still works.
|
|
68
|
+
Constructing `Shipeasy::Client.new(user)` before `Shipeasy.configure` raises
|
|
69
|
+
`Shipeasy::Error`.
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```erb
|
|
71
|
-
<%= i18n_head_tags %>
|
|
72
|
-
<h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
### Anonymous visitors (zero-config bucketing)
|
|
76
|
-
|
|
77
|
-
For logged-out traffic you need a *stable* unit so a fractional rollout buckets
|
|
78
|
-
the same on the server and in the browser. In Rails this is automatic: a Railtie
|
|
79
|
-
mounts `Shipeasy::SDK::RackMiddleware`, which mints the shared `__se_anon_id`
|
|
80
|
-
first-party cookie (read + written by every Shipeasy SDK, including the browser)
|
|
81
|
-
for any request without one. Evaluations then default to it with **no per-call
|
|
82
|
-
wiring** — `get_flag` on an anonymous request just works:
|
|
83
|
-
|
|
84
|
-
```ruby
|
|
85
|
-
# current_user is nil → buckets on the __se_anon_id cookie automatically
|
|
86
|
-
Shipeasy::Client.new({}).get_flag("new_checkout")
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
|
|
90
|
-
yourself it's also on the Rack env as `request.env["shipeasy.anon_id"]`. The
|
|
91
|
-
cookie is non-`HttpOnly` by design so the browser SDK can bucket identically. A
|
|
92
|
-
request with **no** unit still resolves a fully-rolled (100%) gate as on; only
|
|
93
|
-
fractional gates need the id. Cookie name + format are a cross-SDK contract —
|
|
94
|
-
see `18-identity-bucketing.md`.
|
|
95
|
-
|
|
96
|
-
For **Sinatra / Hanami / bare Rack** (no Railtie), mount it yourself:
|
|
97
|
-
|
|
98
|
-
```ruby
|
|
99
|
-
use Shipeasy::SDK::RackMiddleware
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Quickstart (plain Ruby / Sinatra / Hanami / scripts)
|
|
103
|
-
|
|
104
|
-
Same pattern, just without `config/initializers`:
|
|
105
|
-
|
|
106
|
-
```ruby
|
|
107
|
-
require "shipeasy-sdk"
|
|
108
|
-
|
|
109
|
-
Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
|
|
110
|
-
|
|
111
|
-
# With no `attributes` transform, the hash you pass IS the attribute map.
|
|
112
|
-
Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout")
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
The Rails view helpers (`i18n_*`) are not loaded outside Rails, so the
|
|
116
|
-
gem doesn't pull Rails into Sinatra/Hanami apps.
|
|
117
|
-
|
|
118
|
-
## Lambda / Cloud Run / serverless
|
|
119
|
-
|
|
120
|
-
Skip the auto-init facade — it spawns a poll thread you don't want in a
|
|
121
|
-
short-lived function. Build the client explicitly and call `init_once`
|
|
122
|
-
for a single synchronous fetch:
|
|
123
|
-
|
|
124
|
-
```ruby
|
|
125
|
-
engine = Shipeasy::Engine.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
|
|
126
|
-
engine.init_once
|
|
127
|
-
engine.get_flag("new_checkout", user)
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
## Lifecycle escape hatch
|
|
131
|
-
|
|
132
|
-
If you want explicit shutdown control in a long-running worker, build the
|
|
133
|
-
client yourself and skip the singleton:
|
|
134
|
-
|
|
135
|
-
```ruby
|
|
136
|
-
client = Shipeasy::SDK.new_client # reads api_key + base_url from Shipeasy.config
|
|
137
|
-
client.init
|
|
138
|
-
at_exit { client.destroy }
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
## Server-side rendering (SSR)
|
|
142
|
-
|
|
143
|
-
Emit the request's evaluated flags as a declarative `<script>` tag so the
|
|
144
|
-
browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
|
|
145
|
-
in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
|
|
146
|
-
hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
|
|
147
|
-
browser buckets identically to the server.
|
|
148
|
-
|
|
149
|
-
```ruby
|
|
150
|
-
user = { "user_id" => "u_123" }
|
|
151
|
-
|
|
152
|
-
# Two tags for the document <head>. The PUBLIC client key (not the server
|
|
153
|
-
# key) goes on the i18n loader tag.
|
|
154
|
-
head = client.bootstrap_script_tag(user, anon_id: anon_id) +
|
|
155
|
-
client.i18n_script_tag(client_key, profile: "en:prod")
|
|
156
|
-
|
|
157
|
-
# …or get the raw payload ({ "flags", "configs", "experiments", "killswitches" }):
|
|
158
|
-
boot = client.evaluate(user)
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
`bootstrap_script_tag` also accepts `i18n_profile:` and `base_url:` (defaults to
|
|
162
|
-
`https://cdn.shipeasy.ai`). In **Rails**, the existing
|
|
163
|
-
`Shipeasy::I18n::ViewHelpers#i18n_script_tag` view helper still renders the i18n
|
|
164
|
-
loader tag from your app config.
|
|
165
|
-
|
|
166
|
-
## Default values
|
|
167
|
-
|
|
168
|
-
`get_flag` and `get_config` take an optional `default:` returned **only when the
|
|
169
|
-
value cannot be resolved** — never when a flag genuinely evaluates to `false`.
|
|
170
|
-
|
|
171
|
-
```ruby
|
|
172
|
-
# Flag: default is returned only when the client isn't ready yet (no blob
|
|
173
|
-
# fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
|
|
174
|
-
# or outside its rollout) returns false, NOT the default.
|
|
175
|
-
Shipeasy.flags.get_flag("new_checkout", user, default: true)
|
|
176
|
-
|
|
177
|
-
# Config: default is returned when the config key is absent. A decode proc still
|
|
178
|
-
# runs on a present value.
|
|
179
|
-
Shipeasy.flags.get_config("button_color", default: "blue")
|
|
180
|
-
Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
## Evaluation detail
|
|
184
|
-
|
|
185
|
-
`get_flag_detail(name, user)` returns the boolean **and the reason** it was
|
|
186
|
-
reached, as a `FlagDetail` struct (`.value`, `.reason`). `get_flag` is built on
|
|
187
|
-
top of it. The reason is one of the `REASON_*` constants:
|
|
188
|
-
|
|
189
|
-
| Reason | Meaning |
|
|
190
|
-
| ------------------ | --------------------------------------------------- |
|
|
191
|
-
| `OVERRIDE` | answered by a local `override_flag` (no telemetry) |
|
|
192
|
-
| `CLIENT_NOT_READY` | no flag blob fetched/loaded yet |
|
|
193
|
-
| `FLAG_NOT_FOUND` | blob present, but this gate isn't in it |
|
|
194
|
-
| `OFF` | gate present but disabled or killswitched |
|
|
195
|
-
| `RULE_MATCH` | evaluated to `true` |
|
|
196
|
-
| `DEFAULT` | evaluated to `false` (rollout/rule) |
|
|
197
|
-
|
|
198
|
-
```ruby
|
|
199
|
-
detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
|
|
200
|
-
detail.value # => true / false
|
|
201
|
-
detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
The `gate` usage beacon fires exactly once per `get_flag_detail` call (never on
|
|
205
|
-
the `OVERRIDE` short-circuit).
|
|
206
|
-
|
|
207
|
-
## Change listeners
|
|
208
|
-
|
|
209
|
-
`on_change` registers a callback fired after a background poll fetches **new**
|
|
210
|
-
flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
|
|
211
|
-
returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
|
|
212
|
-
no poll thread). A raising listener is isolated and logged, not propagated.
|
|
213
|
-
|
|
214
|
-
```ruby
|
|
215
|
-
unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
|
|
216
|
-
# ... later
|
|
217
|
-
unsubscribe.call
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
## Offline snapshot
|
|
221
|
-
|
|
222
|
-
For CI, air-gapped runs, or reproducing a production decision from a captured
|
|
223
|
-
blob, build a **no-network** client that still runs the real evaluator against a
|
|
224
|
-
snapshot. The snapshot JSON holds the raw response bodies of the two SDK
|
|
225
|
-
endpoints:
|
|
226
|
-
|
|
227
|
-
```json
|
|
228
|
-
{ "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
```ruby
|
|
232
|
-
client = Shipeasy::Engine.from_file("snapshot.json")
|
|
233
|
-
# or, from already-parsed blobs:
|
|
234
|
-
client = Shipeasy::Engine.from_snapshot(flags: flags_body, experiments: exps_body)
|
|
235
|
-
|
|
236
|
-
client.get_flag("new_checkout", user) # real evaluation, no network
|
|
237
|
-
client.get_experiment("checkout_cta", user, {})
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
`init` / `init_once` / `track` are no-ops and telemetry is off (it reuses the
|
|
241
|
-
`for_testing` plumbing). Local `override_*` setters still apply on top of the
|
|
242
|
-
snapshot.
|
|
243
|
-
|
|
244
|
-
## Evaluation details
|
|
71
|
+
## Documentation
|
|
245
72
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
-
|
|
255
|
-
|
|
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).
|
|
256
91
|
|
|
257
92
|
## Testing
|
|
258
93
|
|
|
259
|
-
|
|
260
|
-
returns exactly the values you seed — no api_key, no fetch, no poll thread, no
|
|
261
|
-
telemetry, no metric ingestion. Build one with `Shipeasy::Engine.for_testing` and
|
|
262
|
-
seed each entity with the `override_*` setters (Statsig-style local overrides).
|
|
263
|
-
An override always wins over the fetched blob, so the getters answer
|
|
264
|
-
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.
|
|
265
95
|
|
|
266
96
|
```ruby
|
|
267
97
|
require "shipeasy-sdk"
|
|
268
98
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
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
|
+
)
|
|
275
104
|
|
|
276
|
-
#
|
|
277
|
-
client.
|
|
278
|
-
client.get_config("button_color") # => "blue"
|
|
279
|
-
client.override_config("limits", { "max" => 10 })
|
|
280
|
-
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" })
|
|
281
107
|
|
|
282
|
-
#
|
|
283
|
-
client.
|
|
284
|
-
r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
|
|
285
|
-
r.in_experiment # => true
|
|
286
|
-
r.group # => "treatment"
|
|
287
|
-
r.params # => { label: "Buy now" }
|
|
108
|
+
client.get_flag("new_checkout") # => true
|
|
109
|
+
client.get_config("billing_copy") # => { "title" => "Welcome" }
|
|
288
110
|
|
|
289
|
-
|
|
290
|
-
|
|
111
|
+
result = client.get_experiment("checkout_button", { "color" => "blue" })
|
|
112
|
+
result.in_experiment # => true
|
|
113
|
+
result.group # => "treatment"
|
|
114
|
+
result.params # => { "color" => "green" }
|
|
291
115
|
|
|
292
|
-
#
|
|
293
|
-
client.
|
|
116
|
+
# track / log_exposure are no-ops in test mode — safe to call, send nothing
|
|
117
|
+
client.track("purchase", { amount: 49 })
|
|
294
118
|
```
|
|
295
119
|
|
|
296
|
-
|
|
297
|
-
`
|
|
298
|
-
`Shipeasy::Engine.new(...)`), so you can pin one value in local development while the
|
|
299
|
-
rest comes from the fetched blob.
|
|
300
|
-
|
|
301
|
-
### Global engine / bound client
|
|
302
|
-
|
|
303
|
-
`Shipeasy.engine` (registered by `configure`) and `Shipeasy.flags` (legacy
|
|
304
|
-
singleton) both fetch over the network, so in tests stub them to a
|
|
305
|
-
`for_testing` engine. `Shipeasy::Client.new(user)` reads `Shipeasy.engine`, so
|
|
306
|
-
stubbing the engine also covers the bound-client path:
|
|
307
|
-
|
|
308
|
-
```ruby
|
|
309
|
-
# RSpec
|
|
310
|
-
before do
|
|
311
|
-
test_engine = Shipeasy::Engine.for_testing
|
|
312
|
-
test_engine.override_flag("new_checkout", true)
|
|
313
|
-
allow(Shipeasy).to receive(:engine).and_return(test_engine)
|
|
314
|
-
allow(Shipeasy).to receive(:flags).and_return(test_engine) # legacy path
|
|
315
|
-
|
|
316
|
-
# Shipeasy::Client.new(user).get_flag("new_checkout") now => true
|
|
317
|
-
end
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
## Configuration
|
|
321
|
-
|
|
322
|
-
| Parameter | Default | Description |
|
|
323
|
-
| ------------ | ----------------------------- | ------------------------------------------------------------------- |
|
|
324
|
-
| `api_key` | (required) | SDK key from the Shipeasy dashboard |
|
|
325
|
-
| `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
|
|
326
|
-
| `attributes` | identity (`->(u) { u }`) | Callable mapping your user object → the Shipeasy attribute hash |
|
|
327
|
-
|
|
328
|
-
## Documentation
|
|
329
|
-
|
|
330
|
-
[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.
|
|
331
122
|
|
|
332
123
|
## License
|
|
333
124
|
|
|
334
|
-
[Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use,
|
|
335
|
-
|
|
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`.
|
data/bin/shipeasy-skill
ADDED
data/docs/skill/SKILL.md
ADDED
|
@@ -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`.
|
data/lib/shipeasy/client.rb
CHANGED
|
@@ -55,6 +55,15 @@ module Shipeasy
|
|
|
55
55
|
def get_killswitch(name, switch_key = nil)
|
|
56
56
|
@engine.get_killswitch(name, switch_key)
|
|
57
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
|
|
58
67
|
end
|
|
59
68
|
|
|
60
69
|
# Raised by Shipeasy::Client when constructed before Shipeasy.configure.
|
data/lib/shipeasy/config.rb
CHANGED
|
@@ -22,6 +22,27 @@ module Shipeasy
|
|
|
22
22
|
# ---- experimentation / SDK ----
|
|
23
23
|
attr_accessor :api_key, :base_url
|
|
24
24
|
|
|
25
|
+
# Advanced `configure` options — threaded into the global Engine `configure`
|
|
26
|
+
# builds, so callers never construct an Engine themselves:
|
|
27
|
+
# - env (default "prod"): deployment tag on see() events + usage telemetry.
|
|
28
|
+
# - disable_telemetry (default false): opt out of per-eval usage telemetry.
|
|
29
|
+
# - telemetry_url: override the telemetry endpoint (rarely needed).
|
|
30
|
+
# - private_attributes: attribute keys stripped from every outbound event
|
|
31
|
+
# before it leaves the process (they still drive targeting locally).
|
|
32
|
+
# - sticky_store: pin a user's experiment group across re-buckets.
|
|
33
|
+
attr_accessor :env, :disable_telemetry, :telemetry_url,
|
|
34
|
+
:private_attributes, :sticky_store
|
|
35
|
+
|
|
36
|
+
# Fetch lifecycle for the global engine `configure` builds:
|
|
37
|
+
# - init (default true): fire a one-shot fetch fire-and-forget so the first
|
|
38
|
+
# `Shipeasy::Client.new(user).get_flag(...)` resolves against real rules
|
|
39
|
+
# (ideal for serverless / short-lived processes).
|
|
40
|
+
# - poll (default false): start the background poll (initial fetch +
|
|
41
|
+
# periodic refresh) for a long-running server, so flags stay fresh
|
|
42
|
+
# without a redeploy. Configuration owns the lifecycle — you never call
|
|
43
|
+
# `engine.init` yourself.
|
|
44
|
+
attr_accessor :init, :poll
|
|
45
|
+
|
|
25
46
|
# Optional transform from YOUR user object (any shape) to the Shipeasy
|
|
26
47
|
# attribute hash every flag/experiment evaluation uses. A callable
|
|
27
48
|
# (lambda/proc or anything responding to #call). Default = identity (the
|
|
@@ -42,6 +63,13 @@ module Shipeasy
|
|
|
42
63
|
def initialize
|
|
43
64
|
@base_url = "https://edge.shipeasy.dev"
|
|
44
65
|
@attributes = nil
|
|
66
|
+
@init = true
|
|
67
|
+
@poll = false
|
|
68
|
+
@env = "prod"
|
|
69
|
+
@disable_telemetry = false
|
|
70
|
+
@telemetry_url = nil
|
|
71
|
+
@private_attributes = nil
|
|
72
|
+
@sticky_store = nil
|
|
45
73
|
|
|
46
74
|
@profile = "default"
|
|
47
75
|
@default_chunk = "index"
|
|
@@ -105,23 +133,180 @@ module Shipeasy
|
|
|
105
133
|
@engine
|
|
106
134
|
end
|
|
107
135
|
|
|
108
|
-
# Build + register the one global engine (first-config-wins).
|
|
109
|
-
# one-shot
|
|
136
|
+
# Build + register the one global engine (first-config-wins). Kicks off the
|
|
137
|
+
# configured fetch lifecycle (one-shot by default; the background poll when
|
|
138
|
+
# `c.poll = true`) fire-and-forget. Idempotent within a process.
|
|
110
139
|
def register_engine!(cfg)
|
|
111
140
|
return @engine if @engine && @engine_pid == Process.pid
|
|
112
141
|
@engine_pid = Process.pid
|
|
113
|
-
engine = Engine.new(
|
|
142
|
+
engine = Engine.new(
|
|
143
|
+
api_key: cfg.api_key,
|
|
144
|
+
base_url: cfg.base_url,
|
|
145
|
+
env: cfg.env,
|
|
146
|
+
disable_telemetry: cfg.disable_telemetry,
|
|
147
|
+
telemetry_url: cfg.telemetry_url,
|
|
148
|
+
private_attributes: cfg.private_attributes,
|
|
149
|
+
sticky_store: cfg.sticky_store,
|
|
150
|
+
)
|
|
114
151
|
@engine = engine
|
|
115
152
|
# Capture +engine+ in the closure (not the @engine ivar, which a concurrent
|
|
116
153
|
# reset/reconfigure could nil out before the thread runs).
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
if cfg.poll
|
|
155
|
+
Thread.new do
|
|
156
|
+
engine.init # initial fetch + background poll thread
|
|
157
|
+
rescue => e
|
|
158
|
+
warn "[shipeasy] configure(poll) background poll failed: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
elsif cfg.init
|
|
161
|
+
Thread.new do
|
|
162
|
+
engine.init_once
|
|
163
|
+
rescue => e
|
|
164
|
+
warn "[shipeasy] configure() one-shot fetch failed: #{e.message}"
|
|
165
|
+
end
|
|
121
166
|
end
|
|
122
167
|
engine
|
|
123
168
|
end
|
|
124
169
|
|
|
170
|
+
# ---- configure() test/offline siblings -----------------------------------
|
|
171
|
+
#
|
|
172
|
+
# Drop-in siblings of `Shipeasy.configure` for tests and offline evaluation.
|
|
173
|
+
# Unlike `configure` (first-config-wins), these REPLACE the registered global
|
|
174
|
+
# engine, so a suite can reconfigure between cases. After either, you read the
|
|
175
|
+
# same way: `Shipeasy::Client.new(user)`.
|
|
176
|
+
|
|
177
|
+
# Configure Shipeasy in TEST MODE — no api key, zero network, ever. Seed the
|
|
178
|
+
# values your code under test should see via the override args, then read
|
|
179
|
+
# through the ordinary `Shipeasy::Client.new(user)`:
|
|
180
|
+
#
|
|
181
|
+
# Shipeasy.configure_for_testing(flags: { "new_checkout" => true })
|
|
182
|
+
# Shipeasy::Client.new({ "user_id" => "u_1" }).get_flag("new_checkout") # => true
|
|
183
|
+
#
|
|
184
|
+
# flags: { name => bool } forced get_flag results
|
|
185
|
+
# configs: { name => value } forced get_config results
|
|
186
|
+
# experiments: { name => [group, params] } forced enrolments
|
|
187
|
+
# attributes: same transform as configure (default identity)
|
|
188
|
+
def configure_for_testing(flags: nil, configs: nil, experiments: nil, attributes: nil)
|
|
189
|
+
engine = Engine.for_testing
|
|
190
|
+
apply_overrides(engine, flags, configs, experiments)
|
|
191
|
+
install_global_engine(engine, attributes)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Configure Shipeasy OFFLINE — evaluate the REAL rules from an in-memory
|
|
195
|
+
# snapshot or a JSON file, with no network. Provide exactly one source:
|
|
196
|
+
#
|
|
197
|
+
# snapshot: { "flags" => <body of /sdk/flags>, "experiments" => <body of /sdk/experiments> }
|
|
198
|
+
# path: "snapshot.json" (a JSON file of the same shape)
|
|
199
|
+
#
|
|
200
|
+
# Optional flags/configs/experiments overrides layer on top (same shapes as
|
|
201
|
+
# configure_for_testing). Replaces any previously-configured engine.
|
|
202
|
+
def configure_for_offline(snapshot: nil, path: nil, flags: nil, configs: nil, experiments: nil, attributes: nil)
|
|
203
|
+
engine =
|
|
204
|
+
if path
|
|
205
|
+
Engine.from_file(path)
|
|
206
|
+
elsif snapshot
|
|
207
|
+
s = snapshot.transform_keys(&:to_s)
|
|
208
|
+
Engine.from_snapshot(flags: s["flags"], experiments: s["experiments"])
|
|
209
|
+
else
|
|
210
|
+
raise Error, "Shipeasy.configure_for_offline requires snapshot: or path:"
|
|
211
|
+
end
|
|
212
|
+
apply_overrides(engine, flags, configs, experiments)
|
|
213
|
+
install_global_engine(engine, attributes)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# ---- package-level helpers (so callers never name the Engine) -------------
|
|
217
|
+
|
|
218
|
+
# On-the-spot overrides layered on top of whatever configure_for_testing /
|
|
219
|
+
# configure_for_offline (or a live configure) set up — they win over the blob
|
|
220
|
+
# until clear_overrides. Require a prior configure* call.
|
|
221
|
+
def override_flag(name, value)
|
|
222
|
+
require_engine("override_flag").override_flag(name, value)
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def override_config(name, value)
|
|
227
|
+
require_engine("override_config").override_config(name, value)
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def override_experiment(name, group, params)
|
|
232
|
+
require_engine("override_experiment").override_experiment(name, group, params)
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Drop EVERY override — including the seed from configure_for_testing (test
|
|
237
|
+
# mode has no blob beneath); under configure_for_offline it reverts to the
|
|
238
|
+
# snapshot.
|
|
239
|
+
def clear_overrides
|
|
240
|
+
require_engine("clear_overrides").clear_overrides
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Register a poll listener fired after a background poll fetches NEW data
|
|
245
|
+
# (HTTP 200, not 304). Requires configure(poll: true). Returns an unsubscribe
|
|
246
|
+
# proc. Accepts a block or any callable.
|
|
247
|
+
def on_change(callable = nil, &block)
|
|
248
|
+
require_engine("on_change").on_change(callable, &block)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# SSR tag helpers — delegate to the configured global engine, so you never
|
|
252
|
+
# touch it. i18n_script_tag carries the PUBLIC client key (not the server
|
|
253
|
+
# key); bootstrap_script_tag embeds no key.
|
|
254
|
+
def i18n_script_tag(client_key, profile: "en:prod", base_url: nil)
|
|
255
|
+
require_engine("i18n_script_tag").i18n_script_tag(client_key, profile: profile, base_url: base_url)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def bootstrap_script_tag(user, anon_id: nil, i18n_profile: "en:prod", base_url: nil)
|
|
259
|
+
require_engine("bootstrap_script_tag").bootstrap_script_tag(
|
|
260
|
+
user, anon_id: anon_id, i18n_profile: i18n_profile, base_url: base_url
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# see() structured error reporting — package-level, dispatched through the
|
|
265
|
+
# last-constructed default client (the engine configure built). Never raises
|
|
266
|
+
# into caller code; a call before any client exists warns and no-ops.
|
|
267
|
+
def see(problem)
|
|
268
|
+
Shipeasy::SDK.see(problem)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def see_violation(name)
|
|
272
|
+
Shipeasy::SDK.see_violation(name)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def control_flow_exception(err)
|
|
276
|
+
Shipeasy::SDK.control_flow_exception(err)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Replace the registered global engine + attributes transform (used by the
|
|
280
|
+
# configure_for_* siblings — unlike configure, they replace so a test suite
|
|
281
|
+
# can reconfigure between cases). Returns the engine.
|
|
282
|
+
def install_global_engine(engine, attributes)
|
|
283
|
+
config.attributes = attributes
|
|
284
|
+
@engine = engine
|
|
285
|
+
@engine_pid = Process.pid
|
|
286
|
+
engine
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Apply the configure_for_* override args onto an engine.
|
|
290
|
+
def apply_overrides(engine, flags, configs, experiments)
|
|
291
|
+
(flags || {}).each { |name, value| engine.override_flag(name, value) }
|
|
292
|
+
(configs || {}).each { |name, value| engine.override_config(name, value) }
|
|
293
|
+
(experiments || {}).each do |name, spec|
|
|
294
|
+
group, params = spec # spec is [group, params]
|
|
295
|
+
engine.override_experiment(name, group, params)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The global engine, or raise a helpful error naming the package-level fn the
|
|
300
|
+
# caller used before any configure*.
|
|
301
|
+
def require_engine(fn_name)
|
|
302
|
+
e = engine
|
|
303
|
+
return e unless e.nil?
|
|
304
|
+
|
|
305
|
+
raise Error, "Shipeasy.#{fn_name} called before Shipeasy.configure " \
|
|
306
|
+
"{ |c| c.api_key = … } (or configure_for_testing / " \
|
|
307
|
+
"configure_for_offline). Call one once at app boot."
|
|
308
|
+
end
|
|
309
|
+
|
|
125
310
|
# Reset the config back to defaults — primarily for tests.
|
|
126
311
|
def reset_config!
|
|
127
312
|
@config = nil
|
data/lib/shipeasy/engine.rb
CHANGED
|
@@ -90,15 +90,19 @@ module Shipeasy
|
|
|
90
90
|
|
|
91
91
|
# Build a no-network, immediately-usable client for tests. Telemetry is
|
|
92
92
|
# disabled, init/init_once/track are no-ops (never fetch), and no api_key
|
|
93
|
-
# is required.
|
|
93
|
+
# is required. The client is immediately READY against an empty blob (so a
|
|
94
|
+
# missing gate resolves FLAG_NOT_FOUND, not CLIENT_NOT_READY — parity with
|
|
95
|
+
# the other SDKs). Seed it with override_flag / override_config /
|
|
94
96
|
# override_experiment, then call the normal getters.
|
|
95
97
|
def self.for_testing(env: "prod")
|
|
96
|
-
new(
|
|
98
|
+
client = new(
|
|
97
99
|
api_key: "test",
|
|
98
100
|
env: env,
|
|
99
101
|
disable_telemetry: true,
|
|
100
102
|
test_mode: true,
|
|
101
103
|
)
|
|
104
|
+
client.send(:load_snapshot, {}, {})
|
|
105
|
+
client
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
# Build an offline client from a JSON snapshot file. The file holds the
|
|
@@ -300,17 +304,21 @@ module Shipeasy
|
|
|
300
304
|
|
|
301
305
|
# Read a killswitch from the cached flags blob. Without +switch_key+,
|
|
302
306
|
# returns true when the whole killswitch is killed. With +switch_key+,
|
|
303
|
-
# returns true when that specific per-key switch is on
|
|
304
|
-
#
|
|
307
|
+
# returns true when that specific named per-key switch is on — and when
|
|
308
|
+
# the key isn't configured on the killswitch, FALLS BACK to the top-level
|
|
309
|
+
# value (so an unconfigured key behaves exactly like the no-key call).
|
|
310
|
+
# Unknown killswitches return false. Not user-scoped.
|
|
305
311
|
def get_killswitch(name, switch_key = nil)
|
|
306
312
|
@telemetry.emit("ks", name)
|
|
307
313
|
ks = @mutex.synchronize { @flags_blob&.dig("killswitches", name.to_s) }
|
|
308
314
|
return false unless ks
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
Eval.enabled?(
|
|
315
|
+
unless switch_key.nil?
|
|
316
|
+
switches = ks["switches"] || {}
|
|
317
|
+
key = switch_key.to_s
|
|
318
|
+
return Eval.enabled?(switches[key]) if switches.key?(key)
|
|
319
|
+
# key not configured → fall through to the top-level value
|
|
313
320
|
end
|
|
321
|
+
Eval.enabled?(ks["killed"])
|
|
314
322
|
end
|
|
315
323
|
|
|
316
324
|
# Batch-evaluate every loaded gate, config and experiment for +user+ into
|
|
@@ -11,11 +11,10 @@
|
|
|
11
11
|
# require "open_feature/sdk"
|
|
12
12
|
# require "shipeasy/sdk/openfeature"
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
# client.init
|
|
14
|
+
# Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY"); c.poll = true }
|
|
16
15
|
#
|
|
17
16
|
# OpenFeature::SDK.configure do |config|
|
|
18
|
-
# config.set_provider(Shipeasy::OpenFeature::Provider.new
|
|
17
|
+
# config.set_provider(Shipeasy::OpenFeature::Provider.new) # uses the configured global
|
|
19
18
|
# end
|
|
20
19
|
#
|
|
21
20
|
# of = OpenFeature::SDK.build_client
|
|
@@ -38,6 +37,8 @@ rescue LoadError => e
|
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
require_relative "../engine"
|
|
40
|
+
require_relative "../config"
|
|
41
|
+
require_relative "../client"
|
|
41
42
|
|
|
42
43
|
module Shipeasy
|
|
43
44
|
module OpenFeature
|
|
@@ -66,7 +67,17 @@ module Shipeasy
|
|
|
66
67
|
|
|
67
68
|
attr_reader :metadata
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
# Construct the provider. With no argument it resolves the global engine
|
|
71
|
+
# configured via `Shipeasy.configure(...)`, so callers never build an
|
|
72
|
+
# Engine themselves — construct it AFTER your `Shipeasy.configure` call.
|
|
73
|
+
# Pass an explicit engine only for advanced/multi-key setups.
|
|
74
|
+
def initialize(client = nil)
|
|
75
|
+
client ||= Shipeasy.engine
|
|
76
|
+
if client.nil?
|
|
77
|
+
raise Shipeasy::Error, "Shipeasy::OpenFeature::Provider.new needs " \
|
|
78
|
+
"Shipeasy.configure { |c| c.api_key = … } to have run first " \
|
|
79
|
+
"(or pass an explicit engine)."
|
|
80
|
+
end
|
|
70
81
|
@client = client
|
|
71
82
|
@metadata = OF::ProviderMetadata.new(name: "shipeasy").freeze
|
|
72
83
|
end
|
|
@@ -123,12 +134,28 @@ module Shipeasy
|
|
|
123
134
|
end
|
|
124
135
|
|
|
125
136
|
# OpenFeature `track()` → Shipeasy `track()`. No-ops without a targeting key.
|
|
126
|
-
def track(tracking_event_name, evaluation_context: nil,
|
|
137
|
+
def track(tracking_event_name, evaluation_context: nil, tracking_event_details: nil)
|
|
127
138
|
ctx = normalize_context(evaluation_context)
|
|
128
139
|
user_id = ctx["targeting_key"] || ctx["user_id"]
|
|
129
140
|
return if user_id.nil? || user_id.to_s.empty?
|
|
130
141
|
|
|
131
|
-
props =
|
|
142
|
+
# Base props = the evaluation-context attributes (minus the identity
|
|
143
|
+
# keys), with the tracking-event details merged on top.
|
|
144
|
+
props = ctx.reject { |k, _| k == "targeting_key" || k == "user_id" }
|
|
145
|
+
|
|
146
|
+
detail_fields = if tracking_event_details.respond_to?(:fields)
|
|
147
|
+
tracking_event_details.fields.transform_keys(&:to_s)
|
|
148
|
+
elsif tracking_event_details.is_a?(Hash)
|
|
149
|
+
tracking_event_details.transform_keys(&:to_s)
|
|
150
|
+
else
|
|
151
|
+
{}
|
|
152
|
+
end
|
|
153
|
+
props = props.merge(detail_fields)
|
|
154
|
+
|
|
155
|
+
if tracking_event_details.respond_to?(:value) && !tracking_event_details.value.nil?
|
|
156
|
+
props["value"] = tracking_event_details.value
|
|
157
|
+
end
|
|
158
|
+
|
|
132
159
|
@client.track(user_id, tracking_event_name, props)
|
|
133
160
|
end
|
|
134
161
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Shipeasy
|
|
6
|
+
module SDK
|
|
7
|
+
# `shipeasy-skill` — install the bundled Shipeasy agent skill into a project.
|
|
8
|
+
#
|
|
9
|
+
# RubyGems has no safe post-install hook (gems don't run code on install;
|
|
10
|
+
# installers run non-interactively), so installing the skill is an explicit,
|
|
11
|
+
# opt-in command:
|
|
12
|
+
#
|
|
13
|
+
# shipeasy-skill install # → .claude/skills/shipeasy-ruby/SKILL.md
|
|
14
|
+
# shipeasy-skill install --dir path/ # custom destination (file or dir)
|
|
15
|
+
# shipeasy-skill install --force # overwrite an existing file
|
|
16
|
+
# shipeasy-skill print # write the skill to stdout
|
|
17
|
+
#
|
|
18
|
+
# The skill (`docs/skill/SKILL.md`) is shipped inside the gem, so this reads
|
|
19
|
+
# it with no network — relative to this file, which works both from an
|
|
20
|
+
# installed gem and a source checkout.
|
|
21
|
+
module Skill
|
|
22
|
+
DEFAULT_DEST = ".claude/skills/shipeasy-ruby/SKILL.md"
|
|
23
|
+
|
|
24
|
+
# The bundled SKILL.md, read from docs/skill/SKILL.md (a sibling of lib/ in
|
|
25
|
+
# both the installed gem and a source checkout).
|
|
26
|
+
def self.skill_text
|
|
27
|
+
path = File.expand_path("../../../docs/skill/SKILL.md", __dir__)
|
|
28
|
+
File.read(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Copy the skill to +dest+ (a file, or a directory it's written into).
|
|
32
|
+
def self.install(dest, force: false)
|
|
33
|
+
dest = File.join(dest, "SKILL.md") if File.directory?(dest) || File.extname(dest).empty?
|
|
34
|
+
if File.exist?(dest) && !force
|
|
35
|
+
warn "shipeasy-skill: refusing to overwrite #{dest} — pass --force"
|
|
36
|
+
return 1
|
|
37
|
+
end
|
|
38
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
39
|
+
File.write(dest, skill_text)
|
|
40
|
+
puts "shipeasy-skill: installed the Shipeasy agent skill → #{dest}"
|
|
41
|
+
0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.main(argv)
|
|
45
|
+
cmd = argv.shift
|
|
46
|
+
case cmd
|
|
47
|
+
when "install"
|
|
48
|
+
dest = DEFAULT_DEST
|
|
49
|
+
force = false
|
|
50
|
+
while (arg = argv.shift)
|
|
51
|
+
case arg
|
|
52
|
+
when "--dir" then dest = argv.shift
|
|
53
|
+
when "--force" then force = true
|
|
54
|
+
else
|
|
55
|
+
warn "shipeasy-skill: unknown argument #{arg}"
|
|
56
|
+
return 1
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
install(dest, force: force)
|
|
60
|
+
when "print"
|
|
61
|
+
puts skill_text
|
|
62
|
+
0
|
|
63
|
+
else
|
|
64
|
+
puts <<~USAGE
|
|
65
|
+
shipeasy-skill — install the Shipeasy Ruby agent skill into your project.
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
shipeasy-skill install [--dir PATH] [--force] copy SKILL.md (default: #{DEFAULT_DEST})
|
|
69
|
+
shipeasy-skill print print the skill to stdout
|
|
70
|
+
USAGE
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/shipeasy/sdk/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: shipeasy-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shipeasy, Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|
|
@@ -58,12 +58,15 @@ description: Server SDK for Shipeasy. Polls /sdk/flags and /sdk/experiments, eva
|
|
|
58
58
|
/ i18n_t view helpers for the Shipeasy string-manager CDN.
|
|
59
59
|
email:
|
|
60
60
|
- sdk@shipeasy.ai
|
|
61
|
-
executables:
|
|
61
|
+
executables:
|
|
62
|
+
- shipeasy-skill
|
|
62
63
|
extensions: []
|
|
63
64
|
extra_rdoc_files: []
|
|
64
65
|
files:
|
|
65
66
|
- LICENSE
|
|
66
67
|
- README.md
|
|
68
|
+
- bin/shipeasy-skill
|
|
69
|
+
- docs/skill/SKILL.md
|
|
67
70
|
- lib/shipeasy-sdk.rb
|
|
68
71
|
- lib/shipeasy/client.rb
|
|
69
72
|
- lib/shipeasy/config.rb
|
|
@@ -78,6 +81,7 @@ files:
|
|
|
78
81
|
- lib/shipeasy/sdk/rack_middleware.rb
|
|
79
82
|
- lib/shipeasy/sdk/railtie.rb
|
|
80
83
|
- lib/shipeasy/sdk/see.rb
|
|
84
|
+
- lib/shipeasy/sdk/skill.rb
|
|
81
85
|
- lib/shipeasy/sdk/sticky_store.rb
|
|
82
86
|
- lib/shipeasy/sdk/telemetry.rb
|
|
83
87
|
- lib/shipeasy/sdk/version.rb
|