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 +7 -0
- data/LICENSE +17 -0
- data/README.md +124 -0
- data/lib/togglefleet/version.rb +5 -0
- data/lib/togglefleet.rb +213 -0
- metadata +51 -0
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
|
data/lib/togglefleet.rb
ADDED
|
@@ -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: []
|