shipeasy-sdk 1.0.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4eafaff1d5be1131bb55a64dbbb1653ee18f2a5a9e22cd83a45bb92de7bcfcb6
4
- data.tar.gz: 4b59ebf58017deb5def949f46235d53f6ca60320400ac72ada320eff92d7e5d5
3
+ metadata.gz: 6987470d45b6349d50b279a94bc0fb356812c23f354a743c3c052433dc5759aa
4
+ data.tar.gz: 34d847b481a67fd8876c5e7dfc1351dab1e832fa41f97053324cffb19e24a4c0
5
5
  SHA512:
6
- metadata.gz: cb3f19e0215c65fed6db82a1ef181949ef0cb666a25e02e67103b6840bc79ee3e855e9f784a99f03d9ca54c73a74f431e3d16fd90fca29561317ab4b80b200f0
7
- data.tar.gz: 480ac5a6fdeaef418e34488a668e4431bafa392a54d7e8eae7373541a48d401bdb687408e0759f6269c7263c9d38a11b4b4877930df65b4cd7bde859472b0eca
6
+ metadata.gz: 4a3899f6938f09b3e84f0de89f37aab45caca5ae1449157d9e6efd45274a0b224fadbbcfa54505ee84ff760a3b8309fa5545227b0f41cb2650bf83c48d7cfe1f
7
+ data.tar.gz: c5ea14a6d5e53fe124bb8fbb7de06ed3930975b764964698e4c3dbc771f8e68d1766062777fe527f57b8cbd59924b94b864e8f4ed5d7574aa86b066cd9c6d896
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,145 @@
1
- # shipeasy-sdk
1
+ # shipeasy-sdk (Ruby)
2
2
 
3
- Ruby SDK for the ShipEasy experiment platform. Evaluates feature flags and A/B experiments locally against blobs polled from the ShipEasy edge worker.
3
+ Ruby gem for the [Shipeasy](https://shipeasy.ai) hosted service. Server-side
4
+ gate evaluation, runtime configs, experiments, and metric ingestion.
4
5
 
5
- ## Installation
6
+ > Source-available under the [Shipeasy-SAL 1.0](./LICENSE).
7
+
8
+ ## Install
6
9
 
7
10
  ```ruby
8
11
  # Gemfile
9
12
  gem "shipeasy-sdk"
10
13
  ```
11
14
 
12
- ## Quick start
15
+ ## Quickstart (Rails)
16
+
17
+ `config/initializers/shipeasy.rb` is all you need:
13
18
 
14
19
  ```ruby
15
- require "shipeasy-sdk"
20
+ Shipeasy.configure do |c|
21
+ c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY")
22
+ c.public_key = ENV.fetch("SHIPEASY_CLIENT_KEY") # for i18n view helpers
23
+ c.profile = "default"
24
+ end
25
+ ```
16
26
 
17
- client = Shipeasy::SDK.new_client(api_key: "sdk_live_xxxxx")
18
- client.init # fetches flags/experiments + starts background polling thread
27
+ Anywhere in your app:
19
28
 
20
- user = { user_id: "usr_123", plan: "pro", country: "US" }
29
+ ```ruby
30
+ user = { user_id: current_user.id, plan: current_user.plan }
21
31
 
22
- # Feature flag
23
- if client.get_flag("new_checkout", user)
24
- # show new checkout
32
+ if Shipeasy.flags.get_flag("new_checkout", user)
33
+ # ship it
25
34
  end
26
35
 
27
- # Remote config
28
- color = client.get_config("button_color") # => "blue" (raw value)
36
+ color = Shipeasy.flags.get_config("button_color")
37
+ result = Shipeasy.flags.get_experiment("checkout_cta", user, { label: "Buy now" })
38
+ Shipeasy.flags.track(current_user.id.to_s, "checkout_completed", { revenue: 49.99 })
39
+ ```
40
+
41
+ `Shipeasy.flags` is a lazy, **fork-safe** singleton: the first call from
42
+ each process spawns its own `FlagsClient` and starts the background poll
43
+ thread, including post-fork Puma workers under `preload_app!`. No need
44
+ for `before_worker_boot` hooks or holding a global constant.
45
+
46
+ In a Rails view (the railtie auto-mounts these helpers when Rails is loaded):
47
+
48
+ ```erb
49
+ <%= i18n_head_tags %>
50
+ <h1><%= i18n_t("hero.title", name: current_user.name) %></h1>
51
+ ```
52
+
53
+ ### Anonymous visitors (zero-config bucketing)
54
+
55
+ For logged-out traffic you need a *stable* unit so a fractional rollout buckets
56
+ the same on the server and in the browser. In Rails this is automatic: a Railtie
57
+ mounts `Shipeasy::SDK::RackMiddleware`, which mints the shared `__se_anon_id`
58
+ first-party cookie (read + written by every Shipeasy SDK, including the browser)
59
+ for any request without one. Evaluations then default to it with **no per-call
60
+ wiring** — `get_flag` on an anonymous request just works:
61
+
62
+ ```ruby
63
+ # current_user is nil → buckets on the __se_anon_id cookie automatically
64
+ Shipeasy.flags.get_flag("new_checkout", {})
65
+ ```
66
+
67
+ An explicit `user_id` / `anonymous_id` always wins. If you prefer to read the id
68
+ yourself it's also on the Rack env as `request.env["shipeasy.anon_id"]`. The
69
+ cookie is non-`HttpOnly` by design so the browser SDK can bucket identically. A
70
+ request with **no** unit still resolves a fully-rolled (100%) gate as on; only
71
+ fractional gates need the id. Cookie name + format are a cross-SDK contract —
72
+ see `18-identity-bucketing.md`.
73
+
74
+ For **Sinatra / Hanami / bare Rack** (no Railtie), mount it yourself:
75
+
76
+ ```ruby
77
+ use Shipeasy::SDK::RackMiddleware
78
+ ```
79
+
80
+ ## Quickstart (plain Ruby / Sinatra / Hanami / scripts)
29
81
 
30
- # A/B experiment
31
- result = client.get_experiment("checkout_cta", user, { label: "Buy now" })
32
- puts result.in_experiment # true/false
33
- puts result.group # "control" | "treatment"
34
- puts result.params # { "label" => "Checkout" }
82
+ Same pattern, just without `config/initializers`:
83
+
84
+ ```ruby
85
+ require "shipeasy-sdk"
35
86
 
36
- # Track a metric event (fire-and-forget background thread)
37
- client.track("usr_123", "checkout_completed", { revenue: 49.99 })
87
+ Shipeasy.configure { |c| c.api_key = ENV.fetch("SHIPEASY_SERVER_KEY") }
38
88
 
39
- # Shutdown (stops background poll thread)
40
- client.destroy
89
+ Shipeasy.flags.get_flag("new_checkout", { user_id: "u_1" })
41
90
  ```
42
91
 
43
- ## init vs init_once
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:
44
100
 
45
- - `init` — fetches data, marks initialized, starts the background poll thread. Call once at app boot.
46
- - `init_once` same as `init` but is a no-op if already initialized. Safe to call multiple times (e.g. in middleware).
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
+ ```
47
117
 
48
118
  ## Evaluation details
49
119
 
50
- - **Gates** — rules matched in order; rollout bucket computed as `murmur3("#{salt}:#{uid}") % 10000 < rolloutPct`.
51
- - **Experiments** — checks `status == "running"`, optional targeting gate, universe holdout range, allocation bucket, then group assignment by weight.
52
- - **MurmurHash3** — pure Ruby implementation, x86_32 variant, seed 0.
53
- - **ETag caching** each poll sends `If-None-Match`; a `304` response skips the JSON parse.
54
- - **Poll interval** — initial default 30 s; overridden by `X-Poll-Interval` response header from the flags endpoint.
120
+ - **Gates** — rules matched in order; rollout bucket =
121
+ `murmur3("#{salt}:#{uid}") % 10000 < rollout_pct`.
122
+ - **Experiments** — `status == "running"`, optional targeting gate,
123
+ universe holdout range, allocation bucket, then group assignment by
124
+ weight.
125
+ - **MurmurHash3** — pure-Ruby x86_32 variant, seed 0.
126
+ - **ETag caching** — each poll sends `If-None-Match`; a 304 skips the
127
+ JSON parse.
128
+ - **Poll interval** — defaults to 30 s; overridden by the
129
+ `X-Poll-Interval` header from the flags endpoint.
55
130
 
56
131
  ## Configuration
57
132
 
58
- | Parameter | Default | Description |
59
- |------------|--------------------------------|-----------------------------------|
60
- | `api_key` | (required) | SDK key from the ShipEasy dashboard |
61
- | `base_url` | `https://edge.shipeasy.dev` | Override for local dev / staging |
133
+ | Parameter | Default | Description |
134
+ | ---------- | ------------------------- | ----------------------------------- |
135
+ | `api_key` | (required) | SDK key from the Shipeasy dashboard |
136
+ | `base_url` | `https://cdn.shipeasy.ai` | Override for local dev / staging |
137
+
138
+ ## Documentation
139
+
140
+ [docs.shipeasy.ai](https://docs.shipeasy.ai)
141
+
142
+ ## License
143
+
144
+ [Shipeasy-SAL 1.0](./LICENSE) — source-available, non-commercial-use,
145
+ permitted as a Shipeasy client.
@@ -0,0 +1,93 @@
1
+ # Single configuration object for the Shipeasy gem.
2
+ #
3
+ # Covers both subsystems:
4
+ # - SDK / experimentation (api_key, base_url) — drives FlagsClient
5
+ # - i18n / string manager (public_key, profile, cdn_base_url, ...) — drives
6
+ # the Rails view helpers and label fetcher
7
+ #
8
+ # Usage:
9
+ #
10
+ # Shipeasy.configure do |c|
11
+ # c.api_key = ENV["SHIPEASY_SERVER_KEY"]
12
+ # c.public_key = ENV["SHIPEASY_CLIENT_KEY"]
13
+ # c.profile = "default"
14
+ # end
15
+ #
16
+ # Anything not set falls back to the defaults below. The same Shipeasy.config
17
+ # is read by FlagsClient and the Rails helpers, so there is one place to
18
+ # point environment variables at.
19
+
20
+ module Shipeasy
21
+ class Configuration
22
+ # ---- experimentation / SDK ----
23
+ attr_accessor :api_key, :base_url
24
+
25
+ # ---- i18n / string manager ----
26
+ attr_accessor :public_key, :profile, :default_chunk,
27
+ :cdn_base_url, :loader_url,
28
+ :manifest_cache_ttl, :label_file_cache_ttl, :http_timeout
29
+
30
+ def initialize
31
+ @base_url = "https://edge.shipeasy.dev"
32
+
33
+ @profile = "default"
34
+ @default_chunk = "index"
35
+ @cdn_base_url = "https://cdn.i18n.shipeasy.ai"
36
+ @loader_url = "https://cdn.i18n.shipeasy.ai/loader.js"
37
+ @manifest_cache_ttl = 60
38
+ @label_file_cache_ttl = 3600
39
+ @http_timeout = 1
40
+ end
41
+ end
42
+
43
+ class << self
44
+ def config
45
+ @config ||= Configuration.new
46
+ end
47
+
48
+ def configure
49
+ yield config
50
+ end
51
+
52
+ # Reset the config back to defaults — primarily for tests.
53
+ def reset_config!
54
+ @config = nil
55
+ @flags_pid = nil
56
+ @flags&.destroy
57
+ @flags = nil
58
+ end
59
+
60
+ # Lazy, fork-safe singleton FlagsClient. The first call from each
61
+ # process spawns a fresh client + poll thread — including post-fork
62
+ # workers under Puma's preload_app!. Callers can `Shipeasy.flags.get_flag(...)`
63
+ # straight from a controller without holding a constant or worrying
64
+ # about `before_worker_boot` hooks.
65
+ #
66
+ # Initializers stay minimal:
67
+ #
68
+ # # config/initializers/shipeasy.rb
69
+ # Shipeasy.configure { |c| c.api_key = ENV["SHIPEASY_SERVER_KEY"] }
70
+ #
71
+ # The first request that touches `Shipeasy.flags.*` triggers init().
72
+ # For serverless / Lambda where you want a single fetch with no thread,
73
+ # build the client explicitly: `Shipeasy::SDK::FlagsClient.new(...).init_once`.
74
+ def flags
75
+ pid = Process.pid
76
+ if @flags && @flags_pid != pid
77
+ # Post-fork: parent's poll thread didn't survive. Don't destroy
78
+ # @flags (its mutex/state is invalid in this child anyway); just
79
+ # rebuild from scratch.
80
+ @flags = nil
81
+ end
82
+ @flags ||= begin
83
+ @flags_pid = pid
84
+ client = SDK::FlagsClient.new(
85
+ api_key: config.api_key,
86
+ base_url: config.base_url,
87
+ )
88
+ client.init
89
+ client
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,66 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Shipeasy
7
+ module I18n
8
+ class LabelFetcher
9
+ MANIFEST_KEY_PREFIX = "i18n:manifest:"
10
+ LABEL_KEY_PREFIX = "i18n:label:"
11
+
12
+ def initialize(config = Shipeasy.config)
13
+ @config = config
14
+ end
15
+
16
+ def fetch(profile: @config.profile, chunk: @config.default_chunk)
17
+ manifest = fetch_manifest(profile)
18
+ return nil unless manifest
19
+
20
+ file_url = manifest[chunk]
21
+ return nil unless file_url
22
+
23
+ fetch_label_file(file_url)
24
+ rescue => e
25
+ ::Rails.logger.warn("[Shipeasy::I18n] Failed to fetch labels: #{e.message}") if defined?(::Rails)
26
+ nil
27
+ end
28
+
29
+ private
30
+
31
+ def fetch_manifest(profile)
32
+ cache_key = "#{MANIFEST_KEY_PREFIX}#{@config.public_key}:#{profile}"
33
+ cache_fetch(cache_key, @config.manifest_cache_ttl) do
34
+ url = "#{@config.cdn_base_url}/labels/#{@config.public_key}/#{profile}/manifest.json"
35
+ http_get_json(url)
36
+ end
37
+ end
38
+
39
+ def fetch_label_file(url)
40
+ cache_key = "#{LABEL_KEY_PREFIX}#{Digest::MD5.hexdigest(url)}"
41
+ cache_fetch(cache_key, @config.label_file_cache_ttl) do
42
+ http_get_json(url)
43
+ end
44
+ end
45
+
46
+ def cache_fetch(key, ttl, &block)
47
+ if defined?(::Rails) && ::Rails.cache
48
+ ::Rails.cache.fetch(key, expires_in: ttl.seconds, &block)
49
+ else
50
+ block.call
51
+ end
52
+ end
53
+
54
+ def http_get_json(url)
55
+ uri = URI.parse(url)
56
+ http = Net::HTTP.new(uri.host, uri.port)
57
+ http.use_ssl = (uri.scheme == "https")
58
+ http.open_timeout = @config.http_timeout
59
+ http.read_timeout = @config.http_timeout
60
+ res = http.get(uri.request_uri, { "Accept" => "application/json" })
61
+ raise "HTTP #{res.code} fetching #{url}" unless res.is_a?(Net::HTTPSuccess)
62
+ JSON.parse(res.body)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,14 @@
1
+ module Shipeasy
2
+ module I18n
3
+ # Auto-mounts ViewHelpers into ActionView when the gem is loaded inside
4
+ # a Rails app. Skipped silently when ::Rails isn't defined (plain Ruby
5
+ # consumers of the SDK never see the i18n surface).
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "shipeasy.i18n.view_helpers" do
8
+ ActiveSupport.on_load(:action_view) do
9
+ include Shipeasy::I18n::ViewHelpers
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,49 @@
1
+ module Shipeasy
2
+ module I18n
3
+ module ViewHelpers
4
+ def i18n_head_tags(profile: nil, chunk: nil)
5
+ safe_join([
6
+ i18n_inline_data(profile: profile, chunk: chunk),
7
+ i18n_script_tag,
8
+ ], "\n")
9
+ end
10
+
11
+ def i18n_inline_data(profile: nil, chunk: nil)
12
+ config = Shipeasy.config
13
+ label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
14
+ profile: profile || config.profile,
15
+ chunk: chunk || config.default_chunk,
16
+ )
17
+ return "".html_safe unless label_file
18
+
19
+ json_content = JSON.generate(label_file)
20
+ content_tag(:script, json_content.html_safe, id: "i18n-data", type: "application/json")
21
+ end
22
+
23
+ def i18n_script_tag(hide_until_ready: false)
24
+ config = Shipeasy.config
25
+ attrs = {
26
+ src: config.loader_url,
27
+ "data-key": config.public_key,
28
+ "data-profile": config.profile,
29
+ async: true,
30
+ }
31
+ attrs[:"data-hide-until-ready"] = "true" if hide_until_ready
32
+ tag(:script, attrs)
33
+ end
34
+
35
+ def i18n_t(key, variables = {}, profile: nil, chunk: nil)
36
+ config = Shipeasy.config
37
+ label_file = Shipeasy::I18n::LabelFetcher.new.fetch(
38
+ profile: profile || config.profile,
39
+ chunk: chunk || config.default_chunk,
40
+ )
41
+ return key unless label_file && label_file["strings"]
42
+
43
+ value = label_file["strings"][key] || key
44
+ variables.each { |k, v| value = value.gsub("{{#{k}}}", v.to_s) }
45
+ value
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ require "securerandom"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Anonymous bucketing identity — the cross-SDK `__se_anon_id` cookie.
6
+ #
7
+ # Gates and experiments bucket a unit with murmur3(salt:unit). For a
8
+ # logged-out visitor the unit is a stable anonymous id carried in a single
9
+ # first-party cookie that EVERY Shipeasy SDK (server + browser) reads and
10
+ # writes, so a server render and the browser bucket a fractional rollout
11
+ # identically. The cookie name + format are frozen across every language;
12
+ # see experiment-platform/18-identity-bucketing.md.
13
+ module AnonId
14
+ COOKIE = "__se_anon_id".freeze
15
+ MAX_AGE = 31_536_000 # 1 year, in seconds
16
+
17
+ # The cookie value is client-controllable and feeds bucketing, so a
18
+ # tampered value is treated as absent and a fresh id is minted. UUIDs
19
+ # satisfy this charset.
20
+ VALID_RX = /\A[A-Za-z0-9_-]{1,64}\z/.freeze
21
+
22
+ THREAD_KEY = :shipeasy_anon_id
23
+
24
+ module_function
25
+
26
+ # A fresh opaque bucketing id (UUIDv4).
27
+ def mint
28
+ SecureRandom.uuid
29
+ end
30
+
31
+ def valid?(value)
32
+ value.is_a?(String) && VALID_RX.match?(value)
33
+ end
34
+
35
+ # The anon id RackMiddleware resolved for the current request, or nil when
36
+ # no middleware ran (e.g. a background job). FlagsClient falls back to this
37
+ # as the default anonymous_id, so evaluations need no per-call wiring.
38
+ def current
39
+ Thread.current[THREAD_KEY]
40
+ end
41
+
42
+ def current=(value)
43
+ Thread.current[THREAD_KEY] = value
44
+ end
45
+ end
46
+ end
47
+ end
@@ -66,7 +66,12 @@ module Shipeasy
66
66
  end
67
67
 
68
68
  uid = user["user_id"] || user[:user_id] || user["anonymous_id"] || user[:anonymous_id]
69
- return false unless uid
69
+ # No unit id (an unidentified request before any anon id is minted): a
70
+ # fully-rolled gate is on for everyone, so it can be answered without
71
+ # bucketing; a fractional rollout genuinely needs a stable unit, so deny
72
+ # until one exists. Rules above are still checked, so targeting wins.
73
+ # See experiment-platform/18-identity-bucketing.md.
74
+ return (gate["rolloutPct"] || gate[:rolloutPct] || 0) >= 10000 unless uid
70
75
 
71
76
  salt = gate["salt"] || gate[:salt]
72
77
  murmur3("#{salt}:#{uid}") % 10000 < (gate["rolloutPct"] || gate[:rolloutPct] || 0)
@@ -3,15 +3,26 @@ 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)
13
15
  @api_key = api_key
14
16
  @base_url = (base_url || DEFAULT_BASE_URL).chomp("/")
17
+ # Per-evaluation usage telemetry. ON by default; pass
18
+ # disable_telemetry: true to opt out. See telemetry.rb.
19
+ @telemetry = Telemetry.new(
20
+ endpoint: telemetry_url || Telemetry::DEFAULT_TELEMETRY_URL,
21
+ sdk_key: api_key,
22
+ side: "server",
23
+ env: env,
24
+ disabled: disable_telemetry,
25
+ )
15
26
  @flags_blob = nil
16
27
  @exps_blob = nil
17
28
  @flags_etag = nil
@@ -40,12 +51,14 @@ module Shipeasy
40
51
  end
41
52
 
42
53
  def get_flag(name, user)
54
+ @telemetry.emit("gate", name)
43
55
  gate = @mutex.synchronize { @flags_blob&.dig("gates", name) }
44
56
  return false unless gate
45
- Eval.eval_gate(gate, user.transform_keys(&:to_s))
57
+ Eval.eval_gate(gate, with_anon_id(user))
46
58
  end
47
59
 
48
60
  def get_config(name, decode = nil)
61
+ @telemetry.emit("config", name)
49
62
  entry = @mutex.synchronize { @flags_blob&.dig("configs", name) }
50
63
  return nil unless entry
51
64
  value = entry["value"]
@@ -53,9 +66,10 @@ module Shipeasy
53
66
  end
54
67
 
55
68
  def get_experiment(name, user, default_params, decode = nil)
69
+ @telemetry.emit("experiment", name)
56
70
  flags_blob, exps_blob = @mutex.synchronize { [@flags_blob, @exps_blob] }
57
71
  exp = exps_blob&.dig("experiments", name)
58
- result = Eval.eval_experiment(exp, flags_blob, exps_blob, user.transform_keys(&:to_s))
72
+ result = Eval.eval_experiment(exp, flags_blob, exps_blob, with_anon_id(user))
59
73
  result.params ||= default_params
60
74
 
61
75
  if result.in_experiment && decode
@@ -94,6 +108,24 @@ module Shipeasy
94
108
 
95
109
  private
96
110
 
111
+ # Normalise the user hash to string keys and, when the caller passed no
112
+ # explicit unit, default anonymous_id to the request's __se_anon_id (set by
113
+ # RackMiddleware). Lets `get_flag("x", {})` bucket anonymous traffic with
114
+ # zero per-call wiring. A caller-supplied user_id/anonymous_id always wins.
115
+ def with_anon_id(user)
116
+ u = user.transform_keys(&:to_s)
117
+ has_unit = !blank?(u["user_id"]) || !blank?(u["anonymous_id"])
118
+ unless has_unit
119
+ anon = AnonId.current
120
+ u["anonymous_id"] = anon if anon
121
+ end
122
+ u
123
+ end
124
+
125
+ def blank?(v)
126
+ v.nil? || v == ""
127
+ end
128
+
97
129
  def start_poll
98
130
  @timer = Thread.new do
99
131
  loop do
@@ -0,0 +1,85 @@
1
+ require_relative "anon_id"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Rack middleware that mints the shared `__se_anon_id` bucketing cookie.
6
+ #
7
+ # For every request without a valid `__se_anon_id` cookie it mints a UUIDv4,
8
+ # exposes it for the duration of the request, and Set-Cookies it on the
9
+ # response. Once installed, gate/experiment evaluations with no explicit
10
+ # user_id / anonymous_id automatically bucket on the cookie id — anonymous
11
+ # visitors get stable, SSR/browser-consistent bucketing with zero per-call
12
+ # wiring.
13
+ #
14
+ # Rails apps get this automatically (a Railtie inserts it). For Sinatra /
15
+ # Hanami / bare Rack, add it yourself:
16
+ #
17
+ # use Shipeasy::SDK::RackMiddleware
18
+ #
19
+ # The resolved id is also stored in the Rack env under "shipeasy.anon_id"
20
+ # for callers that prefer to read it explicitly.
21
+ class RackMiddleware
22
+ ENV_KEY = "shipeasy.anon_id".freeze
23
+
24
+ def initialize(app)
25
+ @app = app
26
+ end
27
+
28
+ def call(env)
29
+ id, minted = read_or_mint(env)
30
+ env[ENV_KEY] = id
31
+ AnonId.current = id
32
+ begin
33
+ status, headers, body = @app.call(env)
34
+ ensure
35
+ # Don't leak the id onto the next request handled by this thread.
36
+ AnonId.current = nil
37
+ end
38
+ set_cookie!(headers, id, env) if minted
39
+ [status, headers, body]
40
+ end
41
+
42
+ private
43
+
44
+ def read_or_mint(env)
45
+ raw = parse_cookies(env["HTTP_COOKIE"])[AnonId::COOKIE]
46
+ return [raw, false] if AnonId.valid?(raw)
47
+
48
+ [AnonId.mint, true]
49
+ end
50
+
51
+ def parse_cookies(header)
52
+ out = {}
53
+ return out unless header
54
+
55
+ header.split(/;\s*/).each do |pair|
56
+ k, v = pair.split("=", 2)
57
+ out[k] = v if k && v && !out.key?(k)
58
+ end
59
+ out
60
+ end
61
+
62
+ def set_cookie!(headers, id, env)
63
+ cookie = +"#{AnonId::COOKIE}=#{id}; Path=/; Max-Age=#{AnonId::MAX_AGE}; SameSite=Lax"
64
+ cookie << "; Secure" if https?(env)
65
+
66
+ # Append without clobbering any Set-Cookie the app already emitted, and
67
+ # match the existing header key's case (Rack 3 mandates lowercase).
68
+ key = headers.keys.find { |k| k.respond_to?(:casecmp) && k.casecmp("set-cookie").zero? } || "Set-Cookie"
69
+ existing = headers[key]
70
+ headers[key] =
71
+ case existing
72
+ when nil then cookie
73
+ when Array then existing + [cookie]
74
+ else "#{existing}\n#{cookie}"
75
+ end
76
+ end
77
+
78
+ def https?(env)
79
+ env["HTTPS"] == "on" ||
80
+ env["rack.url_scheme"] == "https" ||
81
+ env["HTTP_X_FORWARDED_PROTO"].to_s.split(",").first.to_s.strip.casecmp("https").zero?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "rack_middleware"
2
+
3
+ module Shipeasy
4
+ module SDK
5
+ # Auto-mounts RackMiddleware in a Rails app so anonymous bucketing works
6
+ # out of the box — no manual `config.middleware.use`. Loaded only when Rails
7
+ # is present (see lib/shipeasy-sdk.rb), so plain Ruby apps are unaffected.
8
+ class Railtie < ::Rails::Railtie
9
+ initializer "shipeasy.sdk.anon_id_middleware" do |app|
10
+ app.middleware.use Shipeasy::SDK::RackMiddleware
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,78 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "digest"
4
+ require "erb"
5
+ require "thread"
6
+
7
+ module Shipeasy
8
+ module SDK
9
+ # Per-evaluation usage telemetry. Fires one fire-and-forget HTTP beacon per
10
+ # evaluation so usage is counted by Cloudflare's native per-path analytics.
11
+ # Mirrors the contract in the TypeScript reference SDK and
12
+ # experiment-platform/15-usage-metering.md. The path carries sha256(api_key)
13
+ # -- never the raw key -- plus side/env, then feature/resource. A long-lived
14
+ # Ruby process emits reliably; the 2s dedup window bounds volume under loops.
15
+ class Telemetry
16
+ DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai"
17
+
18
+ def initialize(endpoint:, sdk_key:, side: "server", env: "prod", disabled: false, dedupe_ms: 2000)
19
+ endpoint = (endpoint || "").chomp("/")
20
+ @disabled = disabled || sdk_key.nil? || sdk_key.empty? || endpoint.empty?
21
+ @dedupe_ms = dedupe_ms
22
+ @last = {}
23
+ @mutex = Mutex.new
24
+ unless @disabled
25
+ key_hash = Digest::SHA256.hexdigest(sdk_key)
26
+ @prefix = "#{endpoint}/t/#{key_hash}/#{side}/#{enc(env)}"
27
+ end
28
+ end
29
+
30
+ # Best-effort usage beacon for one evaluation. Never blocks the caller
31
+ # (the thread owns the request) and never raises into evaluation.
32
+ def emit(feature, resource)
33
+ return if @disabled
34
+
35
+ if @dedupe_ms > 0
36
+ dedupe_key = "#{feature}/#{resource}"
37
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
38
+ duplicate = @mutex.synchronize do
39
+ last = @last[dedupe_key]
40
+ if last && (now - last) < @dedupe_ms
41
+ true
42
+ else
43
+ @last[dedupe_key] = now
44
+ false
45
+ end
46
+ end
47
+ return if duplicate
48
+ end
49
+
50
+ dispatch("#{@prefix}/#{feature}/#{enc(resource)}")
51
+ end
52
+
53
+ private
54
+
55
+ # Fire-and-forget HTTP GET on a background thread. Isolated as its own
56
+ # method so tests can intercept it without real network/timing.
57
+ def dispatch(url)
58
+ Thread.new do
59
+ begin
60
+ uri = URI(url)
61
+ http = Net::HTTP.new(uri.host, uri.port)
62
+ http.use_ssl = uri.scheme == "https"
63
+ http.open_timeout = 2
64
+ http.read_timeout = 2
65
+ http.get(uri.request_uri)
66
+ rescue StandardError
67
+ # telemetry must never affect the caller
68
+ end
69
+ end
70
+ end
71
+
72
+ # encodeURIComponent-equivalent: %20 for space, %2F for slash (NOT "+").
73
+ def enc(value)
74
+ ERB::Util.url_encode(value.to_s)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,5 +1,5 @@
1
1
  module Shipeasy
2
2
  module SDK
3
- VERSION = "1.0.0"
3
+ VERSION = "1.3.0"
4
4
  end
5
5
  end
data/lib/shipeasy-sdk.rb CHANGED
@@ -1,11 +1,27 @@
1
1
  require_relative "shipeasy/sdk/version"
2
+ require_relative "shipeasy/config"
2
3
  require_relative "shipeasy/sdk/murmur3"
3
4
  require_relative "shipeasy/sdk/eval"
4
5
  require_relative "shipeasy/sdk/flags_client"
6
+ require_relative "shipeasy/sdk/anon_id"
7
+ require_relative "shipeasy/sdk/rack_middleware"
8
+ require_relative "shipeasy/i18n/label_fetcher"
9
+
10
+ # Rails-only surface. Skipped on plain Ruby so the gem stays usable in
11
+ # non-Rails apps (Sinatra, Hanami, scripts) without pulling Rails in.
12
+ if defined?(::Rails)
13
+ require_relative "shipeasy/i18n/view_helpers"
14
+ require_relative "shipeasy/i18n/railtie"
15
+ # Auto-mounts RackMiddleware so anonymous bucketing works with no config.
16
+ require_relative "shipeasy/sdk/railtie"
17
+ end
5
18
 
6
19
  module Shipeasy
7
20
  module SDK
8
- def self.new_client(api_key:, base_url: nil)
21
+ # Convenience constructor. Reads api_key + base_url from the gem-wide
22
+ # config when omitted, so a single `Shipeasy.configure { … }` block at
23
+ # boot is enough.
24
+ def self.new_client(api_key: Shipeasy.config.api_key, base_url: Shipeasy.config.base_url)
9
25
  FlagsClient.new(api_key: api_key, base_url: base_url)
10
26
  end
11
27
  end
metadata CHANGED
@@ -1,32 +1,93 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shipeasy-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - ShipEasy
7
+ - Shipeasy, Inc.
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: Server SDK for ShipEasy — polls /sdk/flags and /sdk/experiments, evaluates
13
- flags and experiments locally.
11
+ date: 2026-06-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '3.13'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '3.13'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.71'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.71'
55
+ description: Server SDK for Shipeasy. Polls /sdk/flags and /sdk/experiments, evaluates
56
+ gates and experiments locally, forwards exposures + metrics to /collect, and (when
57
+ loaded inside Rails) auto-mounts i18n_head_tags / i18n_inline_data / i18n_script_tag
58
+ / i18n_t view helpers for the Shipeasy string-manager CDN.
14
59
  email:
15
- - sdk@shipeasy.dev
60
+ - sdk@shipeasy.ai
16
61
  executables: []
17
62
  extensions: []
18
63
  extra_rdoc_files: []
19
64
  files:
65
+ - LICENSE
20
66
  - README.md
21
67
  - lib/shipeasy-sdk.rb
68
+ - lib/shipeasy/config.rb
69
+ - lib/shipeasy/i18n/label_fetcher.rb
70
+ - lib/shipeasy/i18n/railtie.rb
71
+ - lib/shipeasy/i18n/view_helpers.rb
72
+ - lib/shipeasy/sdk/anon_id.rb
22
73
  - lib/shipeasy/sdk/eval.rb
23
74
  - lib/shipeasy/sdk/flags_client.rb
24
75
  - lib/shipeasy/sdk/murmur3.rb
76
+ - lib/shipeasy/sdk/rack_middleware.rb
77
+ - lib/shipeasy/sdk/railtie.rb
78
+ - lib/shipeasy/sdk/telemetry.rb
25
79
  - lib/shipeasy/sdk/version.rb
26
- homepage: https://github.com/shipeasy/sdk-ruby
80
+ homepage: https://github.com/shipeasy-ai/sdk-ruby
27
81
  licenses:
28
- - MIT
29
- metadata: {}
82
+ - Nonstandard
83
+ metadata:
84
+ homepage_uri: https://github.com/shipeasy-ai/sdk-ruby
85
+ source_code_uri: https://github.com/shipeasy-ai/sdk-ruby
86
+ bug_tracker_uri: https://github.com/shipeasy-ai/sdk-ruby/issues
87
+ documentation_uri: https://docs.shipeasy.ai
88
+ license_file: LICENSE
89
+ rubygems_mfa_required: 'true'
90
+ post_install_message:
30
91
  rdoc_options: []
31
92
  require_paths:
32
93
  - lib
@@ -34,14 +95,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
34
95
  requirements:
35
96
  - - ">="
36
97
  - !ruby/object:Gem::Version
37
- version: 2.7.0
98
+ version: '3.0'
38
99
  required_rubygems_version: !ruby/object:Gem::Requirement
39
100
  requirements:
40
101
  - - ">="
41
102
  - !ruby/object:Gem::Version
42
103
  version: '0'
43
104
  requirements: []
44
- rubygems_version: 4.0.6
105
+ rubygems_version: 3.5.22
106
+ signing_key:
45
107
  specification_version: 4
46
- summary: ShipEasy feature flag and experimentation SDK for Ruby
108
+ summary: Shipeasy feature gates, runtime configs, experiments, metrics, and i18n helpers
109
+ — Ruby gem.
47
110
  test_files: []