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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +379 -0
- data/lib/flagstack/client.rb +127 -0
- data/lib/flagstack/configuration.rb +112 -0
- data/lib/flagstack/poller.rb +66 -0
- data/lib/flagstack/railtie.rb +26 -0
- data/lib/flagstack/synchronizer.rb +81 -0
- data/lib/flagstack/telemetry/metric.rb +33 -0
- data/lib/flagstack/telemetry/metric_storage.rb +32 -0
- data/lib/flagstack/telemetry/submitter.rb +83 -0
- data/lib/flagstack/telemetry.rb +99 -0
- data/lib/flagstack/version.rb +3 -0
- data/lib/flagstack.rb +332 -0
- data/lib/generators/flagstack/install_generator.rb +27 -0
- data/lib/generators/flagstack/templates/flagstack.rb.tt +31 -0
- metadata +87 -0
|
@@ -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"
|
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
|