togglefleet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 608c0c99556263faeb1c847374977afa7173f249b83062523d80078891b187ca
4
+ data.tar.gz: 5a498caa5253020f3b096d495801701fba336126cc8e66f5c10e60f7628c5dd1
5
+ SHA512:
6
+ metadata.gz: 7fc721286453dbda46fae8f0bcebb8a4a8adf22a8aaefd1beefeeddffda82b350ecad4ae39fdc1a26abced744116778a2ff31f8b3deae93b1c18a7a1c3b52bdf
7
+ data.tar.gz: 28d5b041acb5538a9681bada4c0f1c7b636784bc5b875b7415a64aa2bfebedd4bad876d6eab447760615780b6cc4657ed6e948a0f763a52be3d03f3edc9c90e3
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ToggleFleet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
17
+ OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # ToggleFleet — Ruby SDK
2
+
3
+ Cloud feature flags for Ruby. All five gates — **boolean, actor, group, % of actors, % of time** —
4
+ evaluated **locally** in your process, so checking a flag is a hash lookup, not a network round-trip.
5
+ The gem refreshes your environment's config in the background using conditional (ETag) requests, and
6
+ its percentage bucketing is byte-identical to the server, so a sticky rollout is the same whether you
7
+ evaluate in-app or via the REST API.
8
+
9
+ ```ruby
10
+ gem "togglefleet"
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```ruby
16
+ # config/initializers/togglefleet.rb
17
+ ToggleFleet.configure do |c|
18
+ c.sdk_key = ENV["TOGGLEFLEET_SDK_KEY"] # one key per environment (Settings → SDK keys)
19
+ c.refresh_interval = 15 # seconds between background refreshes
20
+ c.default = false # fail-safe value if a flag is unknown
21
+ end
22
+
23
+ # Group membership is decided in YOUR code:
24
+ ToggleFleet.register_group(:admins) { |user| user.admin? }
25
+ ToggleFleet.register_group(:internal) { |user| user.email.end_with?("@yourco.com") }
26
+
27
+ ToggleFleet.start # initial fetch + background refresh thread (recommended)
28
+ ```
29
+
30
+ ```ruby
31
+ # anywhere in your app — no network call happens here
32
+ if ToggleFleet.enabled?(:checkout_v2, actor: current_user)
33
+ render "checkout/v2"
34
+ end
35
+ ```
36
+
37
+ ## The gates
38
+
39
+ A flag is **on** for an actor if **any** gate matches (first match wins):
40
+
41
+ | Gate | Turn it on in the dashboard | Effect |
42
+ |------|------------------------------|--------|
43
+ | Boolean | "Fully on" | on for everyone |
44
+ | Actor | add actor IDs | on for those specific actors |
45
+ | Group | add group names | on for actors your registered predicate matches |
46
+ | % of actors | set 0–100 | sticky — the same actors keep it as you ramp |
47
+ | % of time | set 0–100 | random per call |
48
+
49
+ ## Actors
50
+
51
+ An "actor" is anything you want to flag on. The gem derives a stable id:
52
+
53
+ 1. `actor.togglefleet_id` if defined, else
54
+ 2. `actor.id` (works out of the box for ActiveRecord), else
55
+ 3. the value itself (so a plain string, symbol, or integer works).
56
+
57
+ ```ruby
58
+ ToggleFleet.enabled?(:beta, actor: current_user) # uses current_user.id
59
+ ToggleFleet.enabled?(:beta, actor: "account_42") # plain string id
60
+ ToggleFleet.enabled?(:beta) # no actor: boolean / % of time only
61
+ ```
62
+
63
+ You can also pass groups explicitly (in addition to registered predicates):
64
+
65
+ ```ruby
66
+ ToggleFleet.enabled?(:eu_pricing, actor: user, groups: [:eu_customer])
67
+ ```
68
+
69
+ ## Bulk snapshot
70
+
71
+ Resolve every flag at once — useful for handing state to a front end:
72
+
73
+ ```ruby
74
+ ToggleFleet.all(actor: current_user)
75
+ # => { "checkout_v2" => true, "dark_mode" => false, ... }
76
+ ```
77
+
78
+ ## Reliability
79
+
80
+ - **Local evaluation** — `enabled?` never blocks on the network.
81
+ - **Background refresh** — a single daemon thread polls every `refresh_interval` seconds with an
82
+ `If-None-Match` ETag, so unchanged configs cost one `304` and zero parsing.
83
+ - **Fail-safe** — if the service is unreachable, the gem serves the last good config; if it never
84
+ loaded, every flag returns `config.default` (defaults to `false`).
85
+ - **Instrumentation** — hook every evaluation for metrics or logging:
86
+
87
+ ```ruby
88
+ ToggleFleet.configure do |c|
89
+ c.sdk_key = ENV["TOGGLEFLEET_SDK_KEY"]
90
+ c.on_evaluation = ->(flag, actor, result) { StatsD.increment("flag.#{flag}.#{result}") }
91
+ end
92
+ ```
93
+
94
+ ## Multiple environments at once
95
+
96
+ The module-level `ToggleFleet.*` is a singleton, but you can build standalone clients:
97
+
98
+ ```ruby
99
+ prod = ToggleFleet::Client.new(ToggleFleet::Configuration.new.tap { |c| c.sdk_key = ENV["PROD_KEY"] }).start
100
+ staging = ToggleFleet::Client.new(ToggleFleet::Configuration.new.tap { |c| c.sdk_key = ENV["STAGING_KEY"] }).start
101
+ ```
102
+
103
+ ## REST API (any language)
104
+
105
+ No Ruby? Hit the API directly with an environment's SDK key.
106
+
107
+ ```
108
+ GET /v1/evaluate?flag=checkout_v2&actor=dave&groups=admins
109
+ Authorization: Bearer tf_live_…
110
+ → { "flag": "checkout_v2", "enabled": true }
111
+
112
+ GET /v1/config
113
+ Authorization: Bearer tf_live_…
114
+ → { "flags": { "checkout_v2": { "boolean": false, "percentage_of_actors": 25,
115
+ "percentage_of_time": 0, "actors": ["dave"], "groups": ["admins"], "id": "…" } } }
116
+ ```
117
+
118
+ `/v1/config` returns the whole environment so you can cache and evaluate locally (that's what this
119
+ gem does). `/v1/evaluate` does a single server-side check — note that **group** gates are resolved
120
+ client-side, so pass `groups=` when using `/v1/evaluate`.
121
+
122
+ ## License
123
+
124
+ MIT © ToggleFleet
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleFleet
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "digest"
6
+ require "uri"
7
+ require_relative "togglefleet/version"
8
+
9
+ # ToggleFleet — cloud feature flags for Ruby.
10
+ #
11
+ # Built from the ground up (Flipper's gate model as the reference, none of its code).
12
+ # Design goals: evaluate flags LOCALLY so there is zero network on the hot path, refresh
13
+ # the config in the background with conditional (ETag) requests, and fail safe.
14
+ #
15
+ # ToggleFleet.configure { |c| c.sdk_key = ENV["TOGGLEFLEET_SDK_KEY"] }
16
+ # ToggleFleet.register_group(:admins) { |user| user.admin? }
17
+ # ToggleFleet.start # begin background refresh (optional but recommended)
18
+ #
19
+ # ToggleFleet.enabled?(:checkout_v2, actor: current_user) # => true / false
20
+ #
21
+ module ToggleFleet
22
+ class Error < StandardError; end
23
+
24
+ # The five gates, evaluated in order — first match wins (mirrors the server exactly).
25
+ GATES = %i[boolean actor group percentage_of_actors percentage_of_time].freeze
26
+
27
+ class Configuration
28
+ attr_accessor :sdk_key, :url, :refresh_interval, :default,
29
+ :open_timeout, :read_timeout, :logger, :on_evaluation
30
+
31
+ def initialize
32
+ @sdk_key = ENV["TOGGLEFLEET_SDK_KEY"]
33
+ @url = ENV.fetch("TOGGLEFLEET_URL", "https://togglefleet.com")
34
+ @refresh_interval = Integer(ENV.fetch("TOGGLEFLEET_REFRESH", 15)) # seconds
35
+ @default = false # fail-safe result when a flag is unknown or never fetched
36
+ @open_timeout = 3
37
+ @read_timeout = 5
38
+ @logger = nil
39
+ @on_evaluation = nil # ->(flag, actor, result) {} for metrics/logging
40
+ end
41
+ end
42
+
43
+ # A self-contained client. Use ToggleFleet.* for the process-wide singleton, or build your
44
+ # own (e.g. to talk to two environments at once): ToggleFleet::Client.new(config).
45
+ class Client
46
+ attr_reader :config
47
+
48
+ def initialize(config)
49
+ @config = config
50
+ @groups = {} # name => predicate proc
51
+ @flags = {} # flag key => state hash
52
+ @etag = nil
53
+ @loaded = false
54
+ @mutex = Mutex.new
55
+ @poller = nil
56
+ end
57
+
58
+ # Register a group predicate. Group membership is decided in YOUR code, so a flag enabled
59
+ # for :admins turns on for any actor where the block returns true.
60
+ def register_group(name, &block)
61
+ raise ArgumentError, "register_group needs a block" unless block
62
+ @mutex.synchronize { @groups[name.to_s] = block }
63
+ self
64
+ end
65
+
66
+ # Pull the config once and start the background refresh thread. Idempotent.
67
+ def start
68
+ sync
69
+ @mutex.synchronize do
70
+ @poller ||= Thread.new do
71
+ loop do
72
+ sleep(@config.refresh_interval)
73
+ begin; sync; rescue StandardError => e; log("refresh failed: #{e.class}: #{e.message}"); end
74
+ end
75
+ end
76
+ @poller.name = "togglefleet-refresh" if @poller.respond_to?(:name=)
77
+ end
78
+ self
79
+ end
80
+
81
+ # The whole point: evaluate locally, no network call here.
82
+ def enabled?(flag, actor: nil, groups: nil)
83
+ ensure_loaded
84
+ state = @mutex.synchronize { @flags[flag.to_s] }
85
+ result = state ? evaluate(state, actor, groups) : @config.default
86
+ @config.on_evaluation&.call(flag.to_s, actor, result)
87
+ result
88
+ rescue StandardError => e
89
+ log("enabled?(#{flag}) error: #{e.class}: #{e.message}")
90
+ @config.default
91
+ end
92
+
93
+ # Snapshot every known flag for an actor — handy for bootstrapping a JS client.
94
+ def all(actor: nil, groups: nil)
95
+ ensure_loaded
96
+ keys = @mutex.synchronize { @flags.keys }
97
+ keys.each_with_object({}) { |k, h| h[k] = enabled?(k, actor: actor, groups: groups) }
98
+ end
99
+
100
+ # Force a refresh now (returns true if the config changed).
101
+ def sync
102
+ uri = URI.join(@config.url + "/", "v1/config")
103
+ http = Net::HTTP.new(uri.host, uri.port)
104
+ http.use_ssl = uri.scheme == "https"
105
+ http.open_timeout = @config.open_timeout
106
+ http.read_timeout = @config.read_timeout
107
+ req = Net::HTTP::Get.new(uri)
108
+ req["Authorization"] = "Bearer #{@config.sdk_key}"
109
+ req["If-None-Match"] = @etag if @etag
110
+ req["User-Agent"] = "togglefleet-ruby/#{VERSION}"
111
+ res = http.request(req)
112
+
113
+ case res
114
+ when Net::HTTPNotModified
115
+ false
116
+ when Net::HTTPSuccess
117
+ flags = JSON.parse(res.body).fetch("flags", {})
118
+ @mutex.synchronize { @flags = flags; @etag = res["ETag"]; @loaded = true }
119
+ true
120
+ when Net::HTTPUnauthorized
121
+ raise Error, "invalid SDK key (401) — check config.sdk_key"
122
+ else
123
+ raise Error, "config fetch failed: HTTP #{res.code}"
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def ensure_loaded
130
+ return if @loaded
131
+ begin; sync; rescue StandardError => e; log("initial load failed, using defaults: #{e.message}"); end
132
+ end
133
+
134
+ # Mirrors the server's evaluation byte-for-byte (same MD5 bucketing) so a sticky rollout
135
+ # is identical whether you evaluate here or call /v1/evaluate.
136
+ def evaluate(state, actor, explicit_groups)
137
+ return true if state["boolean"]
138
+
139
+ aid = actor_id(actor)
140
+ return true if aid && Array(state["actors"]).include?(aid)
141
+
142
+ member_of = resolve_groups(actor, explicit_groups)
143
+ return true unless (Array(state["groups"]) & member_of).empty?
144
+
145
+ pa = state["percentage_of_actors"].to_i
146
+ if pa.positive? && aid
147
+ bucket = Digest::MD5.hexdigest("#{state['id']}:#{aid}")[0, 8].to_i(16) % 100
148
+ return true if bucket < pa
149
+ end
150
+
151
+ pt = state["percentage_of_time"].to_i
152
+ return true if pt.positive? && rand(100) < pt
153
+
154
+ false
155
+ end
156
+
157
+ # Coerce an actor into a stable string id. Prefer an explicit #togglefleet_id, then #id,
158
+ # else the value itself (so plain strings/symbols/ints work too).
159
+ def actor_id(actor)
160
+ return nil if actor.nil?
161
+ return actor.togglefleet_id.to_s if actor.respond_to?(:togglefleet_id)
162
+ return actor.id.to_s if actor.respond_to?(:id)
163
+ actor.to_s
164
+ end
165
+
166
+ def resolve_groups(actor, explicit_groups)
167
+ names = Array(explicit_groups).map(&:to_s)
168
+ unless actor.nil?
169
+ @mutex.synchronize { @groups.dup }.each do |name, predicate|
170
+ begin
171
+ names << name if predicate.call(actor)
172
+ rescue StandardError => e
173
+ log("group #{name} predicate raised: #{e.message}")
174
+ end
175
+ end
176
+ end
177
+ names.uniq
178
+ end
179
+
180
+ def log(msg)
181
+ @config.logger&.warn("[togglefleet] #{msg}")
182
+ end
183
+ end
184
+
185
+ class << self
186
+ def configure
187
+ @config = Configuration.new
188
+ yield @config if block_given?
189
+ @client = Client.new(@config)
190
+ @config
191
+ end
192
+
193
+ def config
194
+ @config ||= Configuration.new
195
+ end
196
+
197
+ def client
198
+ @client ||= Client.new(config)
199
+ end
200
+
201
+ def register_group(name, &block) = client.register_group(name, &block)
202
+ def start = client.start
203
+ def sync = client.sync
204
+ def enabled?(flag, **opts) = client.enabled?(flag, **opts)
205
+ def all(**opts) = client.all(**opts)
206
+
207
+ # mostly for tests
208
+ def reset!
209
+ @config = nil
210
+ @client = nil
211
+ end
212
+ end
213
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: togglefleet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ToggleFleet
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: ToggleFleet is a cloud feature-flag service. This gem fetches your environment's
13
+ flags, caches them, and evaluates all five gates (boolean, actor, group, % of actors,
14
+ % of time) locally — so checking a flag is a hash lookup, not a network call. Background
15
+ refresh uses conditional ETag requests; evaluation is byte-identical to the server.
16
+ email:
17
+ - support@togglefleet.com
18
+ executables: []
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - lib/togglefleet.rb
25
+ - lib/togglefleet/version.rb
26
+ homepage: https://togglefleet.com
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ homepage_uri: https://togglefleet.com
31
+ source_code_uri: https://github.com/takeaseatventure/togglefleet-ruby
32
+ documentation_uri: https://togglefleet.com/docs
33
+ rubygems_mfa_required: 'true'
34
+ rdoc_options: []
35
+ require_paths:
36
+ - lib
37
+ required_ruby_version: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ">="
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ required_rubygems_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ requirements: []
48
+ rubygems_version: 3.6.9
49
+ specification_version: 4
50
+ summary: Cloud feature flags for Ruby — all five gates, evaluated locally.
51
+ test_files: []