flagstack 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.
@@ -0,0 +1,26 @@
1
+ module Flagstack
2
+ class Railtie < Rails::Railtie
3
+ initializer "flagstack.configure", after: :load_config_initializers do
4
+ # Auto-configure when FLAGSTACK_TOKEN is present
5
+ Flagstack.set_default
6
+ end
7
+
8
+ initializer "flagstack.configure_flipper", after: "flagstack.configure" do
9
+ # If Flagstack is configured, set it as the default Flipper
10
+ next unless Flagstack.configuration && Flagstack.flipper
11
+
12
+ if defined?(Flipper)
13
+ # Set Flagstack's Flipper instance as the default
14
+ Flipper.instance = Flagstack.flipper
15
+ Flagstack.configuration.log("Set Flagstack as default Flipper instance", level: :info)
16
+ end
17
+ end
18
+
19
+ # Graceful shutdown when Rails exits
20
+ config.after_initialize do
21
+ at_exit do
22
+ Flagstack.shutdown if Flagstack.configuration
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,81 @@
1
+ module Flagstack
2
+ # Synchronizes features from Flagstack into the local adapter.
3
+ # This mirrors Flipper Cloud's approach: remote is source of truth,
4
+ # local adapter is kept in sync for fast reads.
5
+ class Synchronizer
6
+ def initialize(client:, flipper:, config:)
7
+ @client = client
8
+ @flipper = flipper
9
+ @config = config
10
+ end
11
+
12
+ # Pull features from Flagstack and write them into local adapter
13
+ def sync
14
+ @config.log("Synchronizing features from Flagstack", level: :debug)
15
+
16
+ data = @client.sync
17
+ return false unless data && data["features"]
18
+
19
+ features = data["features"]
20
+ @config.log("Received #{features.size} features from Flagstack", level: :debug)
21
+
22
+ features.each do |feature_data|
23
+ sync_feature(feature_data)
24
+ end
25
+
26
+ @config.log("Synchronized #{features.size} features to local adapter", level: :info)
27
+ true
28
+ rescue => e
29
+ @config.log("Sync failed: #{e.message}", level: :error)
30
+ false
31
+ end
32
+
33
+ private
34
+
35
+ def sync_feature(feature_data)
36
+ key = feature_data["key"]
37
+ gates = feature_data["gates"] || {}
38
+ enabled = feature_data["enabled"]
39
+ feature = @flipper[key]
40
+
41
+ # Clear existing gates first (disable everything)
42
+ feature.disable
43
+
44
+ # Sync boolean gate from the feature's enabled status
45
+ if enabled == true
46
+ feature.enable
47
+ end
48
+ # If false or nil, feature stays disabled from the disable above
49
+
50
+ # Sync actor gates
51
+ (gates["actors"] || []).each do |actor_id|
52
+ actor = Actor.new(actor_id)
53
+ feature.enable_actor(actor)
54
+ end
55
+
56
+ # Sync group gates
57
+ (gates["groups"] || []).each do |group_name|
58
+ feature.enable_group(group_name.to_sym)
59
+ end
60
+
61
+ # Sync percentage of actors
62
+ if gates["percentage_of_actors"]&.positive?
63
+ feature.enable_percentage_of_actors(gates["percentage_of_actors"])
64
+ end
65
+
66
+ # Sync percentage of time
67
+ if gates["percentage_of_time"]&.positive?
68
+ feature.enable_percentage_of_time(gates["percentage_of_time"])
69
+ end
70
+ end
71
+ end
72
+
73
+ # Simple actor class for sync operations
74
+ class Actor
75
+ attr_reader :flipper_id
76
+
77
+ def initialize(id)
78
+ @flipper_id = id
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,33 @@
1
+ module Flagstack
2
+ class Telemetry
3
+ class Metric
4
+ attr_reader :key, :time, :result
5
+
6
+ def initialize(key, result, time = Time.now)
7
+ @key = key.to_s
8
+ @result = !!result
9
+ @time = time.to_i / 60 * 60 # Round to minute
10
+ end
11
+
12
+ def as_json
13
+ {
14
+ "key" => key,
15
+ "time" => time,
16
+ "result" => result
17
+ }
18
+ end
19
+
20
+ def eql?(other)
21
+ self.class.eql?(other.class) &&
22
+ @key == other.key &&
23
+ @time == other.time &&
24
+ @result == other.result
25
+ end
26
+ alias :== :eql?
27
+
28
+ def hash
29
+ [self.class, @key, @time, @result].hash
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ module Flagstack
2
+ class Telemetry
3
+ class MetricStorage
4
+ def initialize
5
+ @mutex = Mutex.new
6
+ @storage = Hash.new(0)
7
+ end
8
+
9
+ def increment(metric)
10
+ @mutex.synchronize do
11
+ @storage[metric] += 1
12
+ end
13
+ end
14
+
15
+ def drain
16
+ @mutex.synchronize do
17
+ metrics = @storage.dup
18
+ @storage.clear
19
+ metrics.freeze
20
+ end
21
+ end
22
+
23
+ def empty?
24
+ @mutex.synchronize { @storage.empty? }
25
+ end
26
+
27
+ def size
28
+ @mutex.synchronize { @storage.size }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,83 @@
1
+ require "zlib"
2
+ require "json"
3
+ require "securerandom"
4
+
5
+ module Flagstack
6
+ class Telemetry
7
+ class Submitter
8
+ MAX_RETRIES = 3
9
+
10
+ def initialize(client, config)
11
+ @client = client
12
+ @config = config
13
+ @request_id = SecureRandom.uuid
14
+ end
15
+
16
+ def call(metrics)
17
+ return if metrics.empty?
18
+
19
+ body = build_body(metrics)
20
+ compressed = gzip(body)
21
+
22
+ response = submit_with_retry(compressed)
23
+ handle_response(response) if response
24
+ rescue => e
25
+ @config.log("Telemetry submission failed: #{e.message}", level: :error)
26
+ end
27
+
28
+ private
29
+
30
+ def build_body(metrics)
31
+ {
32
+ request_id: @request_id,
33
+ metrics: metrics.map do |metric, count|
34
+ metric.as_json.merge("value" => count)
35
+ end
36
+ }.to_json
37
+ end
38
+
39
+ def gzip(body)
40
+ io = StringIO.new
41
+ io.set_encoding("BINARY")
42
+ gz = Zlib::GzipWriter.new(io)
43
+ gz.write(body)
44
+ gz.close
45
+ io.string
46
+ end
47
+
48
+ def submit_with_retry(body)
49
+ retries = 0
50
+ begin
51
+ @client.post_telemetry(body)
52
+ rescue => e
53
+ retries += 1
54
+ if retries < MAX_RETRIES
55
+ sleep(backoff_delay(retries))
56
+ retry
57
+ end
58
+ raise
59
+ end
60
+ end
61
+
62
+ def backoff_delay(attempt)
63
+ # Exponential backoff: 1s, 2s, 4s
64
+ (2 ** (attempt - 1)) + rand(0.0..0.5)
65
+ end
66
+
67
+ def handle_response(response)
68
+ return unless response
69
+
70
+ # Server can control telemetry via headers
71
+ if (interval = response["Telemetry-Interval"])
72
+ @config.telemetry_interval = interval.to_i
73
+ @config.log("Telemetry interval updated to #{interval}s", level: :debug)
74
+ end
75
+
76
+ if response["Telemetry-Shutdown"] == "true"
77
+ @config.telemetry_enabled = false
78
+ @config.log("Telemetry shutdown requested by server", level: :info)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,99 @@
1
+ module Flagstack
2
+ class Telemetry
3
+ attr_reader :storage
4
+
5
+ def initialize(client, config)
6
+ @client = client
7
+ @config = config
8
+ @storage = MetricStorage.new
9
+ @pid = Process.pid
10
+ @started = false
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def start
15
+ @mutex.synchronize do
16
+ return if @started
17
+ start_timer
18
+ @started = true
19
+ @config.log("Telemetry started (interval: #{@config.telemetry_interval}s)", level: :info)
20
+ end
21
+ end
22
+
23
+ def stop
24
+ @mutex.synchronize do
25
+ return unless @started
26
+ @timer_thread&.kill
27
+ @timer_thread = nil
28
+ flush
29
+ @started = false
30
+ @config.log("Telemetry stopped", level: :info)
31
+ end
32
+ end
33
+
34
+ def record(feature_key, result)
35
+ return unless @config.telemetry_enabled
36
+
37
+ # Fork detection - restart if PID changed
38
+ check_fork
39
+
40
+ metric = Metric.new(feature_key, result)
41
+ @storage.increment(metric)
42
+ end
43
+
44
+ def flush
45
+ return if @storage.empty?
46
+
47
+ metrics = @storage.drain
48
+ submitter = Submitter.new(@client, @config)
49
+
50
+ # Submit in background thread to not block
51
+ Thread.new { submitter.call(metrics) }
52
+ end
53
+
54
+ def running?
55
+ @started
56
+ end
57
+
58
+ private
59
+
60
+ def start_timer
61
+ @timer_thread = Thread.new do
62
+ loop do
63
+ sleep(@config.telemetry_interval)
64
+ flush if @started
65
+ end
66
+ end
67
+ @timer_thread.abort_on_exception = false
68
+ end
69
+
70
+ def check_fork
71
+ return if @pid == Process.pid
72
+
73
+ @mutex.synchronize do
74
+ return if @pid == Process.pid
75
+
76
+ @config.log("Fork detected, restarting telemetry", level: :info)
77
+
78
+ # Drain any pending metrics before fork reset
79
+ # Note: We can't submit in parent process after fork, so we lose these
80
+ # metrics. This is a known limitation of forking servers.
81
+ # The metrics collected in the child process will be submitted normally.
82
+ unless @storage.empty?
83
+ @config.log("Discarding #{@storage.size} metrics from parent process after fork", level: :debug)
84
+ end
85
+
86
+ @pid = Process.pid
87
+ @storage = MetricStorage.new
88
+ @timer_thread&.kill
89
+ @timer_thread = nil
90
+ start_timer if @started
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ # Load nested classes after the Telemetry class is defined
97
+ require "flagstack/telemetry/metric"
98
+ require "flagstack/telemetry/metric_storage"
99
+ require "flagstack/telemetry/submitter"
@@ -0,0 +1,3 @@
1
+ module Flagstack
2
+ VERSION = "0.1.0"
3
+ end
data/lib/flagstack.rb ADDED
@@ -0,0 +1,332 @@
1
+ require "flagstack/version"
2
+ require "flagstack/configuration"
3
+ require "flagstack/client"
4
+ require "flagstack/synchronizer"
5
+ require "flagstack/poller"
6
+ require "flagstack/telemetry"
7
+
8
+ module Flagstack
9
+ class Error < StandardError; end
10
+ class ConfigurationError < Error; end
11
+ class APIError < Error; end
12
+
13
+ class << self
14
+ attr_reader :configuration
15
+
16
+ # Create a new Flagstack-backed Flipper instance
17
+ #
18
+ # flipper = Flagstack.new(token: "fs_live_xxx")
19
+ # flipper.enabled?(:feature)
20
+ # flipper.enabled?(:feature, user)
21
+ #
22
+ def new(options = {})
23
+ config = Configuration.new(options)
24
+ yield config if block_given?
25
+ config.validate!
26
+
27
+ Instance.new(config).flipper
28
+ end
29
+
30
+ # Configure the default Flagstack instance
31
+ # Returns a Flipper instance that reads from local adapter synced with Flagstack
32
+ #
33
+ # Flagstack.configure do |config|
34
+ # config.token = ENV["FLAGSTACK_TOKEN"]
35
+ # end
36
+ #
37
+ # # Now use Flipper as normal
38
+ # Flipper.enabled?(:feature)
39
+ #
40
+ def configure(options = {})
41
+ @configuration = Configuration.new(options)
42
+ yield @configuration if block_given?
43
+ @configuration.validate!
44
+
45
+ @instance = Instance.new(@configuration)
46
+
47
+ # Set as default Flipper instance so Flipper.enabled? uses our adapter
48
+ Flipper.configure do |config|
49
+ config.default { @instance.flipper }
50
+ end
51
+
52
+ # Initial sync
53
+ @instance.sync
54
+
55
+ # Start background polling
56
+ @instance.start_poller if @configuration.sync_method == :poll
57
+
58
+ # Start telemetry
59
+ @instance.start_telemetry
60
+
61
+ @instance.flipper
62
+ end
63
+
64
+ # Get the Flipper instance
65
+ def flipper
66
+ @instance&.flipper
67
+ end
68
+
69
+ # Force sync from Flagstack to local adapter
70
+ def sync
71
+ @instance&.sync
72
+ end
73
+
74
+ # Reset everything (useful for testing)
75
+ def reset!
76
+ @instance&.stop_poller
77
+ @instance&.stop_telemetry
78
+ @instance = nil
79
+ @configuration = nil
80
+ end
81
+
82
+ # Shutdown telemetry and polling gracefully
83
+ def shutdown
84
+ @instance&.stop_telemetry
85
+ @instance&.stop_poller
86
+ end
87
+
88
+ # Check connectivity to Flagstack server
89
+ # Returns { ok: boolean, message: string }
90
+ def health_check
91
+ @instance&.client&.health_check || { ok: false, message: "Not configured" }
92
+ end
93
+
94
+ # ==========================================================================
95
+ # Flipper-compatible API
96
+ # These methods provide a clean abstraction that can be backed by Flipper
97
+ # today, but replaced with a custom backend in the future.
98
+ # ==========================================================================
99
+
100
+ # Check if a feature is enabled
101
+ #
102
+ # Flagstack.enabled?(:new_feature)
103
+ # Flagstack.enabled?(:new_feature, current_user)
104
+ #
105
+ def enabled?(feature, actor = nil)
106
+ return false unless @instance&.flipper
107
+
108
+ if actor
109
+ @instance.flipper.enabled?(feature, actor)
110
+ else
111
+ @instance.flipper.enabled?(feature)
112
+ end
113
+ end
114
+
115
+ # Check if a feature is disabled
116
+ #
117
+ # Flagstack.disabled?(:new_feature)
118
+ # Flagstack.disabled?(:new_feature, current_user)
119
+ #
120
+ def disabled?(feature, actor = nil)
121
+ !enabled?(feature, actor)
122
+ end
123
+
124
+ # Enable a feature globally
125
+ #
126
+ # Flagstack.enable(:new_feature)
127
+ #
128
+ def enable(feature)
129
+ @instance&.flipper&.[](feature)&.enable
130
+ end
131
+
132
+ # Disable a feature globally
133
+ #
134
+ # Flagstack.disable(:new_feature)
135
+ #
136
+ def disable(feature)
137
+ @instance&.flipper&.[](feature)&.disable
138
+ end
139
+
140
+ # Enable a feature for a specific actor
141
+ #
142
+ # Flagstack.enable_actor(:new_feature, current_user)
143
+ #
144
+ def enable_actor(feature, actor)
145
+ @instance&.flipper&.[](feature)&.enable_actor(actor)
146
+ end
147
+
148
+ # Disable a feature for a specific actor
149
+ #
150
+ # Flagstack.disable_actor(:new_feature, current_user)
151
+ #
152
+ def disable_actor(feature, actor)
153
+ @instance&.flipper&.[](feature)&.disable_actor(actor)
154
+ end
155
+
156
+ # Enable a feature for a group
157
+ #
158
+ # Flagstack.enable_group(:new_feature, :admins)
159
+ #
160
+ def enable_group(feature, group)
161
+ @instance&.flipper&.[](feature)&.enable_group(group)
162
+ end
163
+
164
+ # Disable a feature for a group
165
+ #
166
+ # Flagstack.disable_group(:new_feature, :admins)
167
+ #
168
+ def disable_group(feature, group)
169
+ @instance&.flipper&.[](feature)&.disable_group(group)
170
+ end
171
+
172
+ # Enable a feature for a percentage of actors
173
+ #
174
+ # Flagstack.enable_percentage_of_actors(:new_feature, 25)
175
+ #
176
+ def enable_percentage_of_actors(feature, percentage)
177
+ @instance&.flipper&.[](feature)&.enable_percentage_of_actors(percentage)
178
+ end
179
+
180
+ # Disable percentage of actors gate
181
+ #
182
+ # Flagstack.disable_percentage_of_actors(:new_feature)
183
+ #
184
+ def disable_percentage_of_actors(feature)
185
+ @instance&.flipper&.[](feature)&.disable_percentage_of_actors
186
+ end
187
+
188
+ # Enable a feature for a percentage of time
189
+ #
190
+ # Flagstack.enable_percentage_of_time(:new_feature, 50)
191
+ #
192
+ def enable_percentage_of_time(feature, percentage)
193
+ @instance&.flipper&.[](feature)&.enable_percentage_of_time(percentage)
194
+ end
195
+
196
+ # Disable percentage of time gate
197
+ #
198
+ # Flagstack.disable_percentage_of_time(:new_feature)
199
+ #
200
+ def disable_percentage_of_time(feature)
201
+ @instance&.flipper&.[](feature)&.disable_percentage_of_time
202
+ end
203
+
204
+ # Access a feature by name (returns a Feature object)
205
+ #
206
+ # Flagstack[:new_feature].enabled?
207
+ # Flagstack[:new_feature].enable
208
+ #
209
+ def [](feature)
210
+ @instance&.flipper&.[](feature)
211
+ end
212
+
213
+ # List all features
214
+ #
215
+ # Flagstack.features
216
+ #
217
+ def features
218
+ @instance&.flipper&.features || []
219
+ end
220
+
221
+ # Register a group for use with enable_group
222
+ #
223
+ # Flagstack.register(:admins) { |actor| actor.admin? }
224
+ #
225
+ def register(group, &block)
226
+ Flipper.register(group, &block)
227
+ end
228
+
229
+ # Auto-configure when FLAGSTACK_TOKEN is present
230
+ def set_default
231
+ return unless ENV["FLAGSTACK_TOKEN"]
232
+ return if @configuration # Already configured
233
+
234
+ configure
235
+ rescue => e
236
+ # Don't fail app boot if Flagstack is misconfigured
237
+ warn "[Flagstack] Auto-configuration failed: #{e.message}"
238
+ end
239
+ end
240
+
241
+ # Instance holds the configured state for a Flagstack setup
242
+ class Instance
243
+ attr_reader :configuration, :client, :local_adapter, :flipper, :telemetry
244
+
245
+ def initialize(configuration)
246
+ @configuration = configuration
247
+ @client = Client.new(configuration)
248
+ @local_adapter = configuration.local_adapter || default_adapter
249
+ @flipper = Flipper.new(@local_adapter, instrumenter: TelemetryInstrumenter.new(self))
250
+ @synchronizer = Synchronizer.new(
251
+ client: @client,
252
+ flipper: @flipper,
253
+ config: @configuration
254
+ )
255
+ @poller = nil
256
+ @telemetry = Telemetry.new(@client, @configuration)
257
+ end
258
+
259
+ def sync
260
+ @synchronizer.sync
261
+ end
262
+
263
+ def start_poller
264
+ return if @poller&.running?
265
+
266
+ @poller = Poller.new(@synchronizer, @configuration)
267
+ @poller.start
268
+ @configuration.log("Started background poller (interval: #{@configuration.sync_interval}s)", level: :info)
269
+ end
270
+
271
+ def stop_poller
272
+ @poller&.stop
273
+ @poller = nil
274
+ end
275
+
276
+ def start_telemetry
277
+ return unless @configuration.telemetry_enabled
278
+ @telemetry.start
279
+ end
280
+
281
+ def stop_telemetry
282
+ @telemetry&.stop
283
+ end
284
+
285
+ def record_telemetry(feature_key, result)
286
+ @telemetry&.record(feature_key, result)
287
+ end
288
+
289
+ private
290
+
291
+ def default_adapter
292
+ # Try ActiveRecord first, fall back to Memory
293
+ if defined?(Flipper::Adapters::ActiveRecord)
294
+ begin
295
+ Flipper::Adapters::ActiveRecord.new
296
+ rescue => e
297
+ @configuration.log("Could not create ActiveRecord adapter: #{e.message}, using Memory", level: :warn)
298
+ Flipper::Adapters::Memory.new
299
+ end
300
+ else
301
+ Flipper::Adapters::Memory.new
302
+ end
303
+ end
304
+ end
305
+
306
+ # Instrumenter that records telemetry for feature flag checks
307
+ class TelemetryInstrumenter
308
+ def initialize(instance)
309
+ @instance = instance
310
+ end
311
+
312
+ def instrument(name, payload = {})
313
+ result = yield payload if block_given?
314
+
315
+ # Record telemetry for feature_operation events
316
+ if name == "feature_operation.flipper" && payload[:operation] == :enabled?
317
+ feature_name = payload[:feature_name]
318
+ @instance.record_telemetry(feature_name, payload[:result]) if feature_name
319
+ end
320
+
321
+ result
322
+ end
323
+ end
324
+ end
325
+
326
+ # Load Railtie if Rails is present
327
+ require "flagstack/railtie" if defined?(Rails::Railtie)
328
+
329
+ # Auto-configure when token is present (after Rails initializers if in Rails)
330
+ unless defined?(Rails::Railtie)
331
+ Flagstack.set_default
332
+ end