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 +4 -4
- data/LICENSE +40 -0
- data/README.md +256 -34
- data/lib/shipeasy/config.rb +93 -0
- data/lib/shipeasy/i18n/label_fetcher.rb +66 -0
- data/lib/shipeasy/i18n/railtie.rb +14 -0
- data/lib/shipeasy/i18n/view_helpers.rb +49 -0
- data/lib/shipeasy/sdk/anon_id.rb +47 -0
- data/lib/shipeasy/sdk/eval.rb +6 -1
- data/lib/shipeasy/sdk/flags_client.rb +229 -8
- data/lib/shipeasy/sdk/rack_middleware.rb +85 -0
- data/lib/shipeasy/sdk/railtie.rb +14 -0
- data/lib/shipeasy/sdk/telemetry.rb +78 -0
- data/lib/shipeasy/sdk/version.rb +1 -1
- data/lib/shipeasy-sdk.rb +17 -1
- metadata +76 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bacc8b94c722361a53df3156e4dc63159c75b0657e2c1f6ae246593e90cf7c12
|
|
4
|
+
data.tar.gz: 4450034aaee670f04015f396a960646fff27e5195e13ebd93ace4c032a82f023
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
15
|
+
## Quickstart (Rails)
|
|
16
|
+
|
|
17
|
+
`config/initializers/shipeasy.rb` is all you need:
|
|
13
18
|
|
|
14
19
|
```ruby
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
client.init # fetches flags/experiments + starts background polling thread
|
|
27
|
+
Anywhere in your app:
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
```ruby
|
|
30
|
+
user = { user_id: current_user.id, plan: current_user.plan }
|
|
21
31
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# show new checkout
|
|
32
|
+
if Shipeasy.flags.get_flag("new_checkout", user)
|
|
33
|
+
# ship it
|
|
25
34
|
end
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
client.track("usr_123", "checkout_completed", { revenue: 49.99 })
|
|
135
|
+
## Evaluation detail
|
|
38
136
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
- **
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
| `api_key` | (required)
|
|
61
|
-
| `base_url` | `https://
|
|
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
|
data/lib/shipeasy/sdk/eval.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/shipeasy/sdk/version.rb
CHANGED
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
|
-
|
|
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.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
-
|
|
7
|
+
- Shipeasy, Inc.
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
13
|
-
|
|
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.
|
|
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
|
-
-
|
|
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:
|
|
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:
|
|
105
|
+
rubygems_version: 3.5.22
|
|
106
|
+
signing_key:
|
|
45
107
|
specification_version: 4
|
|
46
|
-
summary:
|
|
108
|
+
summary: Shipeasy feature gates, runtime configs, experiments, metrics, and i18n helpers
|
|
109
|
+
— Ruby gem.
|
|
47
110
|
test_files: []
|