magick-feature-flags 0.8.0 → 0.8.2
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 +4 -4
- data/README.md +9 -2
- data/lib/magick/config.rb +15 -2
- data/lib/magick/feature.rb +38 -1
- data/lib/magick/performance_metrics.rb +111 -5
- data/lib/magick/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a53f3c93e974822f126ed2148d9fb61adf7de3a374ba26acbd834a2a916545a0
|
|
4
|
+
data.tar.gz: 31dbbacc15565a496e65113364d3eaf5a6a5385251f3654a28ea1c5eb9e9b014
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b39439b771eb158d39496758362675db1134ea160311b8af9d51b59e1b40c7440ee425ee7d8f6e28bd02dc3da6498ec24d79744cd5cb7b63f54e13e86fa8cf4
|
|
7
|
+
data.tar.gz: 0bc2519decc3164d6b3dddd6080bf79c00b9a1ee2473d95b25550edbea8ca8c433912b8655d72c86b993762526781377df27219f1957585990b59942e3597bb8
|
data/README.md
CHANGED
|
@@ -221,8 +221,15 @@ Create `config/features.rb`:
|
|
|
221
221
|
|
|
222
222
|
```ruby
|
|
223
223
|
# Boolean features
|
|
224
|
-
boolean_feature :new_dashboard,
|
|
225
|
-
|
|
224
|
+
boolean_feature :new_dashboard,
|
|
225
|
+
default: false,
|
|
226
|
+
name: "New Dashboard",
|
|
227
|
+
description: "New dashboard UI"
|
|
228
|
+
|
|
229
|
+
boolean_feature :dark_mode,
|
|
230
|
+
default: false,
|
|
231
|
+
name: "Dark Mode",
|
|
232
|
+
description: "Dark mode theme"
|
|
226
233
|
|
|
227
234
|
# String features
|
|
228
235
|
string_feature :api_version, default: "v1", description: "API version"
|
data/lib/magick/config.rb
CHANGED
|
@@ -44,8 +44,16 @@ module Magick
|
|
|
44
44
|
configure_redis_adapter(url: url, namespace: namespace, **options)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def performance_metrics(enabled: true, **_options)
|
|
48
|
-
|
|
47
|
+
def performance_metrics(enabled: true, redis_tracking: nil, batch_size: 100, flush_interval: 60, **_options)
|
|
48
|
+
return unless enabled
|
|
49
|
+
|
|
50
|
+
@performance_metrics = PerformanceMetrics.new(batch_size: batch_size, flush_interval: flush_interval)
|
|
51
|
+
# Enable Redis tracking if Redis adapter is configured (or explicitly set)
|
|
52
|
+
if redis_tracking.nil?
|
|
53
|
+
# Auto-enable if Redis adapter exists
|
|
54
|
+
redis_tracking = !@redis_url.nil? || configure_redis_adapter != nil
|
|
55
|
+
end
|
|
56
|
+
@performance_metrics.enable_redis_tracking(enable: redis_tracking) if @performance_metrics
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def audit_log(enabled: true, adapter: nil)
|
|
@@ -86,6 +94,11 @@ module Magick
|
|
|
86
94
|
Magick.audit_log = audit_log if audit_log
|
|
87
95
|
Magick.versioning = versioning if versioning
|
|
88
96
|
Magick.warn_on_deprecated = warn_on_deprecated
|
|
97
|
+
|
|
98
|
+
# Enable Redis tracking for performance metrics if Redis adapter is configured
|
|
99
|
+
if Magick.performance_metrics && adapter_registry.is_a?(Adapters::Registry) && adapter_registry.redis_adapter
|
|
100
|
+
Magick.performance_metrics.enable_redis_tracking(enable: true)
|
|
101
|
+
end
|
|
89
102
|
end
|
|
90
103
|
|
|
91
104
|
private
|
data/lib/magick/feature.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Magick
|
|
|
8
8
|
VALID_TYPES = %i[boolean string number].freeze
|
|
9
9
|
VALID_STATUSES = %i[active inactive deprecated].freeze
|
|
10
10
|
|
|
11
|
-
attr_reader :name, :type, :status, :default_value, :description, :adapter_registry
|
|
11
|
+
attr_reader :name, :type, :status, :default_value, :description, :display_name, :adapter_registry
|
|
12
12
|
|
|
13
13
|
def initialize(name, adapter_registry, **options)
|
|
14
14
|
@name = name.to_s
|
|
@@ -17,6 +17,7 @@ module Magick
|
|
|
17
17
|
@status = (options[:status] || :active).to_sym
|
|
18
18
|
@default_value = options.fetch(:default_value, default_for_type)
|
|
19
19
|
@description = options[:description]
|
|
20
|
+
@display_name = options[:name] || options[:display_name]
|
|
20
21
|
@targeting = {}
|
|
21
22
|
@dependencies = options[:dependencies] ? Array(options[:dependencies]) : []
|
|
22
23
|
|
|
@@ -318,6 +319,7 @@ module Magick
|
|
|
318
319
|
adapter_registry.set(name, 'status', status)
|
|
319
320
|
adapter_registry.set(name, 'default_value', default_value)
|
|
320
321
|
adapter_registry.set(name, 'description', description) if description
|
|
322
|
+
adapter_registry.set(name, 'display_name', display_name) if display_name
|
|
321
323
|
@stored_value = value
|
|
322
324
|
|
|
323
325
|
# Update registered feature instance if it exists
|
|
@@ -382,6 +384,9 @@ module Magick
|
|
|
382
384
|
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
383
385
|
end
|
|
384
386
|
|
|
387
|
+
# Cascade disable: disable all features that depend on this one
|
|
388
|
+
disable_dependent_features(user_id: user_id)
|
|
389
|
+
|
|
385
390
|
# Rails 8+ event
|
|
386
391
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
387
392
|
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
@@ -410,6 +415,7 @@ module Magick
|
|
|
410
415
|
def to_h
|
|
411
416
|
{
|
|
412
417
|
name: name,
|
|
418
|
+
display_name: display_name,
|
|
413
419
|
type: type,
|
|
414
420
|
status: status,
|
|
415
421
|
value: stored_value,
|
|
@@ -437,6 +443,7 @@ module Magick
|
|
|
437
443
|
registered = Magick.features[name]
|
|
438
444
|
registered.instance_variable_set(:@stored_value, @stored_value)
|
|
439
445
|
registered.instance_variable_set(:@status, @status)
|
|
446
|
+
registered.instance_variable_set(:@display_name, @display_name)
|
|
440
447
|
registered.instance_variable_set(:@targeting, @targeting.dup)
|
|
441
448
|
end
|
|
442
449
|
self
|
|
@@ -448,6 +455,8 @@ module Magick
|
|
|
448
455
|
@stored_value = load_value_from_adapter
|
|
449
456
|
status_value = adapter_registry.get(name, 'status')
|
|
450
457
|
@status = status_value ? status_value.to_sym : status
|
|
458
|
+
display_name_value = adapter_registry.get(name, 'display_name')
|
|
459
|
+
@display_name = display_name_value if display_name_value
|
|
451
460
|
targeting_value = adapter_registry.get(name, 'targeting')
|
|
452
461
|
if targeting_value.is_a?(Hash)
|
|
453
462
|
# Normalize keys to symbols and handle nested structures
|
|
@@ -655,6 +664,34 @@ module Magick
|
|
|
655
664
|
context
|
|
656
665
|
end
|
|
657
666
|
|
|
667
|
+
def disable_dependent_features(user_id: nil)
|
|
668
|
+
# Find all features that depend on this feature
|
|
669
|
+
dependent_features = find_dependent_features
|
|
670
|
+
|
|
671
|
+
# Disable each dependent feature
|
|
672
|
+
dependent_features.each do |dep_feature_name|
|
|
673
|
+
dep_feature = Magick.features[dep_feature_name.to_s] || Magick[dep_feature_name]
|
|
674
|
+
next unless dep_feature
|
|
675
|
+
|
|
676
|
+
# Only disable if it's currently enabled (to avoid unnecessary operations)
|
|
677
|
+
if dep_feature.enabled?
|
|
678
|
+
dep_feature.disable(user_id: user_id)
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def find_dependent_features
|
|
684
|
+
# Find all features that have this feature in their dependencies
|
|
685
|
+
dependent_features = []
|
|
686
|
+
Magick.features.each do |_name, feature|
|
|
687
|
+
feature_deps = feature.instance_variable_get(:@dependencies) || []
|
|
688
|
+
if feature_deps.include?(name.to_s) || feature_deps.include?(name.to_sym)
|
|
689
|
+
dependent_features << feature.name
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
dependent_features
|
|
693
|
+
end
|
|
694
|
+
|
|
658
695
|
def enable_targeting(type, value)
|
|
659
696
|
@targeting[type] ||= []
|
|
660
697
|
@targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
|
|
@@ -24,17 +24,25 @@ module Magick
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def initialize
|
|
27
|
+
def initialize(batch_size: 100, flush_interval: 60)
|
|
28
28
|
@metrics = []
|
|
29
29
|
@mutex = Mutex.new
|
|
30
30
|
@usage_count = Hash.new(0)
|
|
31
|
+
@pending_updates = Hash.new(0) # For Redis batching
|
|
32
|
+
@batch_size = batch_size
|
|
33
|
+
@flush_interval = flush_interval
|
|
34
|
+
@last_flush = Time.now
|
|
35
|
+
@redis_enabled = false
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
def record(feature_name, operation, duration, success: true)
|
|
34
|
-
|
|
39
|
+
feature_name_str = feature_name.to_s
|
|
40
|
+
metric = Metric.new(feature_name_str, operation, duration, success: success)
|
|
41
|
+
|
|
35
42
|
@mutex.synchronize do
|
|
36
43
|
@metrics << metric
|
|
37
|
-
@usage_count[
|
|
44
|
+
@usage_count[feature_name_str] += 1
|
|
45
|
+
@pending_updates[feature_name_str] += 1
|
|
38
46
|
# Keep only last 1000 metrics
|
|
39
47
|
@metrics.shift if @metrics.length > 1000
|
|
40
48
|
end
|
|
@@ -44,9 +52,64 @@ module Magick
|
|
|
44
52
|
Magick::Rails::Events.usage_tracked(feature_name, operation: operation, duration: duration, success: success)
|
|
45
53
|
end
|
|
46
54
|
|
|
55
|
+
# Batch flush to Redis if needed
|
|
56
|
+
flush_to_redis_if_needed
|
|
57
|
+
|
|
47
58
|
metric
|
|
48
59
|
end
|
|
49
60
|
|
|
61
|
+
def flush_to_redis_if_needed
|
|
62
|
+
return unless @redis_enabled
|
|
63
|
+
return if @pending_updates.empty?
|
|
64
|
+
|
|
65
|
+
should_flush = false
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
# Flush if we have enough pending updates or enough time has passed
|
|
68
|
+
if @pending_updates.size >= @batch_size || (Time.now - @last_flush) >= @flush_interval
|
|
69
|
+
should_flush = true
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
flush_to_redis if should_flush
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def flush_to_redis
|
|
77
|
+
updates_to_flush = nil
|
|
78
|
+
@mutex.synchronize do
|
|
79
|
+
return if @pending_updates.empty?
|
|
80
|
+
|
|
81
|
+
updates_to_flush = @pending_updates.dup
|
|
82
|
+
@pending_updates.clear
|
|
83
|
+
@last_flush = Time.now
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
return if updates_to_flush.nil? || updates_to_flush.empty?
|
|
87
|
+
|
|
88
|
+
# Update Redis in batch
|
|
89
|
+
begin
|
|
90
|
+
adapter = Magick.adapter_registry || Magick.default_adapter_registry
|
|
91
|
+
if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
|
|
92
|
+
redis = adapter.redis_adapter.instance_variable_get(:@redis)
|
|
93
|
+
if redis
|
|
94
|
+
updates_to_flush.each do |feature_name, count|
|
|
95
|
+
redis_key = "magick:stats:#{feature_name}"
|
|
96
|
+
redis.incrby(redis_key, count)
|
|
97
|
+
redis.expire(redis_key, 86400 * 7) # Keep stats for 7 days
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
# Silently fail - don't break feature checks if stats fail
|
|
103
|
+
warn "Magick: Failed to flush stats to Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def enable_redis_tracking(enable: true)
|
|
108
|
+
@redis_enabled = enable
|
|
109
|
+
# Flush any pending updates when enabling
|
|
110
|
+
flush_to_redis if enable && !@pending_updates.empty?
|
|
111
|
+
end
|
|
112
|
+
|
|
50
113
|
def average_duration(feature_name: nil, operation: nil)
|
|
51
114
|
filtered = @metrics.select do |m|
|
|
52
115
|
(feature_name.nil? || m.feature_name == feature_name.to_s) &&
|
|
@@ -59,17 +122,60 @@ module Magick
|
|
|
59
122
|
end
|
|
60
123
|
|
|
61
124
|
def usage_count(feature_name)
|
|
62
|
-
|
|
125
|
+
feature_name_str = feature_name.to_s
|
|
126
|
+
memory_count = @usage_count[feature_name_str] || 0
|
|
127
|
+
|
|
128
|
+
# Also check Redis if enabled
|
|
129
|
+
redis_count = 0
|
|
130
|
+
if @redis_enabled
|
|
131
|
+
begin
|
|
132
|
+
adapter = Magick.adapter_registry || Magick.default_adapter_registry
|
|
133
|
+
if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
|
|
134
|
+
redis = adapter.redis_adapter.instance_variable_get(:@redis)
|
|
135
|
+
if redis
|
|
136
|
+
redis_key = "magick:stats:#{feature_name_str}"
|
|
137
|
+
redis_count = redis.get(redis_key).to_i
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError
|
|
141
|
+
# Silently fail
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
memory_count + redis_count
|
|
63
146
|
end
|
|
64
147
|
|
|
65
148
|
def most_used_features(limit: 10)
|
|
66
|
-
|
|
149
|
+
# Combine memory and Redis counts
|
|
150
|
+
combined_counts = @usage_count.dup
|
|
151
|
+
|
|
152
|
+
if @redis_enabled
|
|
153
|
+
begin
|
|
154
|
+
adapter = Magick.adapter_registry || Magick.default_adapter_registry
|
|
155
|
+
if adapter.is_a?(Magick::Adapters::Registry) && adapter.redis_adapter
|
|
156
|
+
redis = adapter.redis_adapter.instance_variable_get(:@redis)
|
|
157
|
+
if redis
|
|
158
|
+
# Get all stats keys
|
|
159
|
+
redis.keys("magick:stats:*").each do |key|
|
|
160
|
+
feature_name = key.to_s.sub('magick:stats:', '')
|
|
161
|
+
redis_count = redis.get(key).to_i
|
|
162
|
+
combined_counts[feature_name] = (combined_counts[feature_name] || 0) + redis_count
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError
|
|
167
|
+
# Silently fail
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
combined_counts.sort_by { |_name, count| -count }.first(limit).to_h
|
|
67
172
|
end
|
|
68
173
|
|
|
69
174
|
def clear!
|
|
70
175
|
@mutex.synchronize do
|
|
71
176
|
@metrics.clear
|
|
72
177
|
@usage_count.clear
|
|
178
|
+
@pending_updates.clear
|
|
73
179
|
end
|
|
74
180
|
end
|
|
75
181
|
end
|
data/lib/magick/version.rb
CHANGED