Ruby gem for the Shipeasy hosted service. Server-side gate evaluation, runtime configs, experiments, and metric ingestion.
Source-available under the Shipeasy-SAL 1.0.
# Gemfile
gem "shipeasy-sdk"config/initializers/shipeasy.rb is all you need:
Shipeasy.configure do |c|
c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
c.profile = "default"
endAnywhere in your app:
user = { user_id: current_user.id, plan: current_user.plan }
if Shipeasy.flags.get_flag("new_checkout", user)
# ship it
end
color = Shipeasy.flags.get_config("button_color")
result = Shipeasy.flags.get_experiment("checkout_cta", user, { label: "Buy now" })
Shipeasy.flags.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })Shipeasy.flags is a lazy, fork-safe singleton: the first call from
each process spawns its own FlagsClient and starts the background poll
thread, including post-fork Puma workers under preload_app!. No need
for before_worker_boot hooks or holding a global constant.
In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
<%= i18n_head_tags %>
<h1><%= i18n_t("hero.title", name: current_user.name) %></h1>For logged-out traffic you need a stable unit so a fractional rollout buckets
the same on the server and in the browser. In Rails this is automatic: a Railtie
mounts Shipeasy::SDK::RackMiddleware, which mints the shared __se_anon_id
first-party cookie (read + written by every Shipeasy SDK, including the browser)
for any request without one. Evaluations then default to it with no per-call
wiring — get_flag on an anonymous request just works:
# current_user is nil → buckets on the __se_anon_id cookie automatically
Shipeasy.flags.get_flag("new_checkout", {})An explicit user_id / anonymous_id always wins. If you prefer to read the id
yourself it's also on the Rack env as request.env["shipeasy.anon_id"]. The
cookie is non-HttpOnly by design so the browser SDK can bucket identically. A
request with no unit still resolves a fully-rolled (100%) gate as on; only
fractional gates need the id. Cookie name + format are a cross-SDK contract —
see 18-identity-bucketing.md.
For Sinatra / Hanami / bare Rack (no Railtie), mount it yourself:
use Shipeasy::SDK::RackMiddlewareSame pattern, just without config/initializers:
require "shipeasy-sdk"
Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
Shipeasy.flags.get_flag("new_checkout", { user_id: "u_1" })The Rails view helpers (i18n_*) are not loaded outside Rails, so the
gem doesn't pull Rails into Sinatra/Hanami apps.
Skip the auto-init facade — it spawns a poll thread you don't want in a
short-lived function. Build the client explicitly and call init_once
for a single synchronous fetch:
client = Shipeasy::SDK::FlagsClient.new(api_key: ENV.fetch("SHIPEASY_SERVER_KEY"))
client.init_once
client.get_flag("new_checkout", user)If you want explicit shutdown control in a long-running worker, build the client yourself and skip the singleton:
client = Shipeasy::SDK.new_client # reads api_key + base_url from Shipeasy.config
client.init
at_exit { client.destroy }get_flag and get_config take an optional default: returned only when the
value cannot be resolved — never when a flag genuinely evaluates to false.
# Flag: default is returned only when the client isn't ready yet (no blob
# fetched) or the gate doesn't exist. A gate that evaluates to false (disabled,
# or outside its rollout) returns false, NOT the default.
Shipeasy.flags.get_flag("new_checkout", user, default: true)
# Config: default is returned when the config key is absent. A decode proc still
# runs on a present value.
Shipeasy.flags.get_config("button_color", default: "blue")
Shipeasy.flags.get_config("limits", ->(v) { v["max"] }, default: 0)get_flag_detail(name, user) returns the boolean and the reason it was
reached, as a FlagDetail struct (.value, .reason). get_flag is built on
top of it. The reason is one of the REASON_* constants:
| Reason | Meaning |
|---|---|
OVERRIDE |
answered by a local override_flag (no telemetry) |
CLIENT_NOT_READY |
no flag blob fetched/loaded yet |
FLAG_NOT_FOUND |
blob present, but this gate isn't in it |
OFF |
gate present but disabled or killswitched |
RULE_MATCH |
evaluated to true |
DEFAULT |
evaluated to false (rollout/rule) |
detail = Shipeasy.flags.get_flag_detail("new_checkout", user)
detail.value # => true / false
detail.reason # => "RULE_MATCH" / "DEFAULT" / "OFF" / ...The gate usage beacon fires exactly once per get_flag_detail call (never on
the OVERRIDE short-circuit).
on_change registers a callback fired after a background poll fetches new
flag/config data (HTTP 200, not a 304). It accepts a block or any callable and
returns an unsubscribe proc. Listeners never fire in test/offline mode (there is
no poll thread). A raising listener is isolated and logged, not propagated.
unsubscribe = Shipeasy.flags.on_change { reload_local_cache! }
# ... later
unsubscribe.callFor CI, air-gapped runs, or reproducing a production decision from a captured blob, build a no-network client that still runs the real evaluator against a snapshot. The snapshot JSON holds the raw response bodies of the two SDK endpoints:
{ "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }client = Shipeasy::SDK::FlagsClient.from_file("snapshot.json")
# or, from already-parsed blobs:
client = Shipeasy::SDK::FlagsClient.from_snapshot(flags: flags_body, experiments: exps_body)
client.get_flag("new_checkout", user) # real evaluation, no network
client.get_experiment("checkout_cta", user, {})init / init_once / track are no-ops and telemetry is off (it reuses the
for_testing plumbing). Local override_* setters still apply on top of the
snapshot.
- Gates — rules matched in order; rollout bucket =
murmur3("#{salt}:#{uid}") % 10000 < rollout_pct. - Experiments —
status == "running", optional targeting gate, universe holdout range, allocation bucket, then group assignment by weight. - MurmurHash3 — pure-Ruby x86_32 variant, seed 0.
- ETag caching — each poll sends
If-None-Match; a 304 skips the JSON parse. - Poll interval — defaults to 30 s; overridden by the
X-Poll-Intervalheader from the flags endpoint.
For unit/integration tests you want a client that does zero network and
returns exactly the values you seed — no api_key, no fetch, no poll thread, no
telemetry, no metric ingestion. Build one with FlagsClient.for_testing and
seed each entity with the override_* setters (Statsig-style local overrides).
An override always wins over the fetched blob, so the getters answer
deterministically:
require "shipeasy-sdk"
client = Shipeasy::SDK::FlagsClient.for_testing
# init / init_once are no-ops here — nothing is ever fetched.
# Flags (boolean)
client.override_flag("new_checkout", true)
client.get_flag("new_checkout", { user_id: "u_1" }) # => true
# Configs (any value; an optional decode proc still runs)
client.override_config("button_color", "blue")
client.get_config("button_color") # => "blue"
client.override_config("limits", { "max" => 10 })
client.get_config("limits", ->(v) { v["max"] }) # => 10
# Experiments — returns an in-experiment Eval::ExperimentResult
client.override_experiment("checkout_cta", "treatment", { label: "Buy now" })
r = client.get_experiment("checkout_cta", { user_id: "u_1" }, { label: "default" })
r.in_experiment # => true
r.group # => "treatment"
r.params # => { label: "Buy now" }
# track is a no-op (no thread, no network) — assert call counts without stubbing.
client.track("u_1", "checkout_completed", { revenue: 49.99 }) # => nil
# Reset between examples
client.clear_overridesThe same override_flag / override_config / override_experiment /
clear_overrides setters also work on a normal live client (built with
FlagsClient.new(...)), so you can pin one value in local development while the
rest comes from the fetched blob.
Shipeasy.flags is a process-wide singleton that fetches over the network, so
in tests prefer a for_testing client. If a code path reaches through
Shipeasy.flags directly, stub the singleton to the test client in your test
setup:
# RSpec
before do
test_client = Shipeasy::SDK::FlagsClient.for_testing
test_client.override_flag("new_checkout", true)
allow(Shipeasy).to receive(:flags).and_return(test_client)
end| Parameter | Default | Description |
|---|---|---|
api_key |
(required) | SDK key from the Shipeasy dashboard |
base_url |
https://cdn.shipeasy.ai |
Override for local dev / staging |
Shipeasy-SAL 1.0 — source-available, non-commercial-use, permitted as a Shipeasy client.