magick-feature-flags 0.7.4 → 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 +58 -2
- data/lib/generators/magick/install/templates/magick.rb +2 -0
- data/lib/magick/adapters/memory.rb +0 -2
- data/lib/magick/adapters/registry.rb +37 -39
- data/lib/magick/config.rb +20 -18
- data/lib/magick/dsl.rb +9 -0
- data/lib/magick/export_import.rb +4 -2
- data/lib/magick/feature.rb +181 -65
- data/lib/magick/performance_metrics.rb +111 -5
- data/lib/magick/rails/event_subscriber.rb +2 -2
- data/lib/magick/rails/railtie.rb +8 -6
- data/lib/magick/rails.rb +1 -3
- data/lib/magick/testing_helpers.rb +3 -3
- data/lib/magick/version.rb +1 -1
- data/lib/magick/versioning.rb +9 -11
- data/lib/magick.rb +12 -3
- metadata +2 -2
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
|
@@ -166,14 +166,70 @@ feature.enable_for_ip_addresses('192.168.1.0/24', '10.0.0.1')
|
|
|
166
166
|
feature.enable_for_custom_attribute(:subscription_tier, ['premium', 'enterprise'])
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
### Checking Feature Enablement with Objects
|
|
170
|
+
|
|
171
|
+
You can check if a feature is enabled for an object (like a User model) and its fields:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# Using enabled_for? with an object
|
|
175
|
+
user = User.find(123)
|
|
176
|
+
if Magick.enabled_for?(:premium_features, user)
|
|
177
|
+
# Feature is enabled for this user
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Or using the feature directly
|
|
181
|
+
feature = Magick[:premium_features]
|
|
182
|
+
if feature.enabled_for?(user)
|
|
183
|
+
# Feature is enabled for this user
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# With additional context
|
|
187
|
+
if Magick.enabled_for?(:premium_features, user, ip_address: request.remote_ip)
|
|
188
|
+
# Feature is enabled for this user and IP
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Works with ActiveRecord objects, hashes, or simple IDs
|
|
192
|
+
Magick.enabled_for?(:feature, user) # ActiveRecord object
|
|
193
|
+
Magick.enabled_for?(:feature, { id: 123, role: 'admin' }) # Hash
|
|
194
|
+
Magick.enabled_for?(:feature, 123) # Simple ID
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The `enabled_for?` method automatically extracts:
|
|
198
|
+
- `user_id` from `id` or `user_id` attribute
|
|
199
|
+
- `group` from `group` attribute
|
|
200
|
+
- `role` from `role` attribute
|
|
201
|
+
- `ip_address` from `ip_address` attribute
|
|
202
|
+
- All other attributes for custom attribute matching
|
|
203
|
+
|
|
204
|
+
### Return Values
|
|
205
|
+
|
|
206
|
+
All enable/disable methods now return `true` to indicate success:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# All these methods return true on success
|
|
210
|
+
result = feature.enable # => true
|
|
211
|
+
result = feature.disable # => true
|
|
212
|
+
result = feature.enable_for_user(123) # => true
|
|
213
|
+
result = feature.enable_for_group('beta') # => true
|
|
214
|
+
result = feature.enable_percentage_of_users(25) # => true
|
|
215
|
+
result = feature.set_value(true) # => true
|
|
216
|
+
```
|
|
217
|
+
|
|
169
218
|
### DSL Configuration
|
|
170
219
|
|
|
171
220
|
Create `config/features.rb`:
|
|
172
221
|
|
|
173
222
|
```ruby
|
|
174
223
|
# Boolean features
|
|
175
|
-
boolean_feature :new_dashboard,
|
|
176
|
-
|
|
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"
|
|
177
233
|
|
|
178
234
|
# String features
|
|
179
235
|
string_feature :api_version, default: "v1", description: "API version"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Magick
|
|
4
4
|
module Adapters
|
|
5
5
|
class Registry
|
|
6
|
-
CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'
|
|
6
|
+
CACHE_INVALIDATION_CHANNEL = 'magick:cache:invalidate'
|
|
7
7
|
|
|
8
8
|
def initialize(memory_adapter, redis_adapter = nil, circuit_breaker: nil, async: false)
|
|
9
9
|
@memory_adapter = memory_adapter
|
|
@@ -43,36 +43,36 @@ module Magick
|
|
|
43
43
|
memory_adapter.set(feature_name, key, value)
|
|
44
44
|
|
|
45
45
|
# Update Redis if available
|
|
46
|
-
|
|
47
|
-
update_redis = proc do
|
|
48
|
-
circuit_breaker.call do
|
|
49
|
-
redis_adapter.set(feature_name, key, value)
|
|
50
|
-
# Publish cache invalidation message to notify other processes
|
|
51
|
-
publish_cache_invalidation(feature_name)
|
|
52
|
-
end
|
|
53
|
-
rescue AdapterError => e
|
|
54
|
-
# Log error but don't fail - memory is updated
|
|
55
|
-
warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
56
|
-
end
|
|
46
|
+
return unless redis_adapter
|
|
57
47
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
48
|
+
update_redis = proc do
|
|
49
|
+
circuit_breaker.call do
|
|
50
|
+
redis_adapter.set(feature_name, key, value)
|
|
51
|
+
# Publish cache invalidation message to notify other processes
|
|
52
|
+
publish_cache_invalidation(feature_name)
|
|
62
53
|
end
|
|
54
|
+
rescue AdapterError => e
|
|
55
|
+
# Log error but don't fail - memory is updated
|
|
56
|
+
warn "Failed to update Redis: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if @async && defined?(Thread)
|
|
60
|
+
Thread.new { update_redis.call }
|
|
61
|
+
else
|
|
62
|
+
update_redis.call
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def delete(feature_name)
|
|
67
67
|
memory_adapter.delete(feature_name)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
return unless redis_adapter
|
|
69
|
+
|
|
70
|
+
begin
|
|
71
|
+
redis_adapter.delete(feature_name)
|
|
72
|
+
# Publish cache invalidation message
|
|
73
|
+
publish_cache_invalidation(feature_name)
|
|
74
|
+
rescue AdapterError
|
|
75
|
+
# Continue even if Redis fails
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -108,23 +108,21 @@ module Magick
|
|
|
108
108
|
return unless redis_adapter && defined?(Thread)
|
|
109
109
|
|
|
110
110
|
@subscriber_thread = Thread.new do
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
memory_adapter.delete(feature_name)
|
|
120
|
-
end
|
|
111
|
+
redis_client = redis_adapter.instance_variable_get(:@redis)
|
|
112
|
+
return unless redis_client
|
|
113
|
+
|
|
114
|
+
@subscriber = redis_client.dup
|
|
115
|
+
@subscriber.subscribe(CACHE_INVALIDATION_CHANNEL) do |on|
|
|
116
|
+
on.message do |_channel, feature_name|
|
|
117
|
+
# Invalidate memory cache for this feature
|
|
118
|
+
memory_adapter.delete(feature_name)
|
|
121
119
|
end
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
# If subscription fails, log and retry after a delay
|
|
124
|
-
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
125
|
-
sleep 5
|
|
126
|
-
retry
|
|
127
120
|
end
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
# If subscription fails, log and retry after a delay
|
|
123
|
+
warn "Cache invalidation subscriber error: #{e.message}" if defined?(Rails) && Rails.env.development?
|
|
124
|
+
sleep 5
|
|
125
|
+
retry
|
|
128
126
|
end
|
|
129
127
|
@subscriber_thread.abort_on_exception = false
|
|
130
128
|
end
|
data/lib/magick/config.rb
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Magick
|
|
4
4
|
class Config
|
|
5
|
-
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning
|
|
6
|
-
|
|
7
|
-
attr_accessor :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
5
|
+
attr_accessor :adapter_registry, :performance_metrics, :audit_log, :versioning, :warn_on_deprecated,
|
|
6
|
+
:async_updates, :memory_ttl, :circuit_breaker_threshold, :circuit_breaker_timeout, :redis_url, :redis_namespace, :environment
|
|
8
7
|
|
|
9
8
|
def initialize
|
|
10
9
|
@warn_on_deprecated = false
|
|
@@ -45,28 +44,26 @@ module Magick
|
|
|
45
44
|
configure_redis_adapter(url: url, namespace: namespace, **options)
|
|
46
45
|
end
|
|
47
46
|
|
|
48
|
-
def performance_metrics(enabled: true, **
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
53
55
|
end
|
|
56
|
+
@performance_metrics.enable_redis_tracking(enable: redis_tracking) if @performance_metrics
|
|
54
57
|
end
|
|
55
58
|
|
|
56
59
|
def audit_log(enabled: true, adapter: nil)
|
|
57
|
-
if enabled
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@audit_log = nil
|
|
61
|
-
end
|
|
60
|
+
@audit_log = if enabled
|
|
61
|
+
adapter ? AuditLog.new(adapter) : AuditLog.new
|
|
62
|
+
end
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
def versioning(enabled: true)
|
|
65
|
-
if enabled
|
|
66
|
-
@versioning = Versioning.new(adapter_registry || default_adapter_registry)
|
|
67
|
-
else
|
|
68
|
-
@versioning = nil
|
|
69
|
-
end
|
|
66
|
+
@versioning = (Versioning.new(adapter_registry || default_adapter_registry) if enabled)
|
|
70
67
|
end
|
|
71
68
|
|
|
72
69
|
def circuit_breaker(threshold: nil, timeout: nil)
|
|
@@ -97,6 +94,11 @@ module Magick
|
|
|
97
94
|
Magick.audit_log = audit_log if audit_log
|
|
98
95
|
Magick.versioning = versioning if versioning
|
|
99
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
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
private
|
data/lib/magick/dsl.rb
CHANGED
|
@@ -70,6 +70,15 @@ module Magick
|
|
|
70
70
|
def disable_feature(feature_name, user_id: nil)
|
|
71
71
|
Magick[feature_name].disable(user_id: user_id)
|
|
72
72
|
end
|
|
73
|
+
|
|
74
|
+
# Check enablement for an object
|
|
75
|
+
def enabled_for?(feature_name, object, **additional_context)
|
|
76
|
+
Magick.enabled_for?(feature_name, object, **additional_context)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def disabled_for?(feature_name, object, **additional_context)
|
|
80
|
+
Magick.disabled_for?(feature_name, object, **additional_context)
|
|
81
|
+
end
|
|
73
82
|
end
|
|
74
83
|
end
|
|
75
84
|
|
data/lib/magick/export_import.rb
CHANGED
|
@@ -5,7 +5,7 @@ require 'json'
|
|
|
5
5
|
module Magick
|
|
6
6
|
class ExportImport
|
|
7
7
|
def self.export(features_hash)
|
|
8
|
-
result = features_hash.map do |
|
|
8
|
+
result = features_hash.map do |_name, feature|
|
|
9
9
|
feature.to_h
|
|
10
10
|
end
|
|
11
11
|
|
|
@@ -45,7 +45,9 @@ module Magick
|
|
|
45
45
|
description: feature_data['description'] || feature_data[:description]
|
|
46
46
|
)
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
if feature_data['value'] || feature_data[:value]
|
|
49
|
+
feature.set_value(feature_data['value'] || feature_data[:value])
|
|
50
|
+
end
|
|
49
51
|
|
|
50
52
|
# Import targeting
|
|
51
53
|
if feature_data['targeting'] || feature_data[:targeting]
|
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
|
|
|
@@ -69,28 +70,20 @@ module Magick
|
|
|
69
70
|
return false if status == :deprecated && !context[:allow_deprecated]
|
|
70
71
|
|
|
71
72
|
# Check feature dependencies
|
|
72
|
-
|
|
73
|
-
return false unless dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
|
|
74
|
-
end
|
|
73
|
+
return false if !dependencies.empty? && !dependencies.all? { |dep_name| Magick.enabled?(dep_name, context) }
|
|
75
74
|
|
|
76
75
|
# Check date/time range targeting
|
|
77
|
-
if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
78
|
-
return false
|
|
79
|
-
end
|
|
76
|
+
return false if targeting[:date_range] && !date_range_active?(targeting[:date_range])
|
|
80
77
|
|
|
81
78
|
# Check IP address targeting
|
|
82
|
-
if targeting[:ip_address] && context[:ip_address]
|
|
83
|
-
return false unless ip_address_matches?(context[:ip_address])
|
|
84
|
-
end
|
|
79
|
+
return false if targeting[:ip_address] && context[:ip_address] && !ip_address_matches?(context[:ip_address])
|
|
85
80
|
|
|
86
81
|
# Check custom attributes
|
|
87
|
-
if targeting[:custom_attributes]
|
|
88
|
-
return false unless custom_attributes_match?(context, targeting[:custom_attributes])
|
|
89
|
-
end
|
|
82
|
+
return false if targeting[:custom_attributes] && !custom_attributes_match?(context, targeting[:custom_attributes])
|
|
90
83
|
|
|
91
84
|
# Check complex conditions
|
|
92
|
-
if targeting[:complex_conditions]
|
|
93
|
-
return false
|
|
85
|
+
if targeting[:complex_conditions] && !complex_conditions_match?(context, targeting[:complex_conditions])
|
|
86
|
+
return false
|
|
94
87
|
end
|
|
95
88
|
|
|
96
89
|
value = get_value(context)
|
|
@@ -100,7 +93,7 @@ module Magick
|
|
|
100
93
|
when :string
|
|
101
94
|
!value.nil? && value != ''
|
|
102
95
|
when :number
|
|
103
|
-
value.to_f
|
|
96
|
+
value.to_f.positive?
|
|
104
97
|
else
|
|
105
98
|
false
|
|
106
99
|
end
|
|
@@ -110,6 +103,18 @@ module Magick
|
|
|
110
103
|
!enabled?(context)
|
|
111
104
|
end
|
|
112
105
|
|
|
106
|
+
def enabled_for?(object, **additional_context)
|
|
107
|
+
# Extract context from object
|
|
108
|
+
context = extract_context_from_object(object)
|
|
109
|
+
# Merge with any additional context provided
|
|
110
|
+
context.merge!(additional_context)
|
|
111
|
+
enabled?(context)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def disabled_for?(object, **additional_context)
|
|
115
|
+
!enabled_for?(object, **additional_context)
|
|
116
|
+
end
|
|
117
|
+
|
|
113
118
|
def value(context = {})
|
|
114
119
|
get_value(context)
|
|
115
120
|
end
|
|
@@ -125,26 +130,32 @@ module Magick
|
|
|
125
130
|
|
|
126
131
|
def enable_for_user(user_id)
|
|
127
132
|
enable_targeting(:user, user_id)
|
|
133
|
+
true
|
|
128
134
|
end
|
|
129
135
|
|
|
130
136
|
def disable_for_user(user_id)
|
|
131
137
|
disable_targeting(:user, user_id)
|
|
138
|
+
true
|
|
132
139
|
end
|
|
133
140
|
|
|
134
141
|
def enable_for_group(group_name)
|
|
135
142
|
enable_targeting(:group, group_name)
|
|
143
|
+
true
|
|
136
144
|
end
|
|
137
145
|
|
|
138
146
|
def disable_for_group(group_name)
|
|
139
147
|
disable_targeting(:group, group_name)
|
|
148
|
+
true
|
|
140
149
|
end
|
|
141
150
|
|
|
142
151
|
def enable_for_role(role_name)
|
|
143
152
|
enable_targeting(:role, role_name)
|
|
153
|
+
true
|
|
144
154
|
end
|
|
145
155
|
|
|
146
156
|
def disable_for_role(role_name)
|
|
147
157
|
disable_targeting(:role, role_name)
|
|
158
|
+
true
|
|
148
159
|
end
|
|
149
160
|
|
|
150
161
|
def enable_percentage_of_users(percentage)
|
|
@@ -152,18 +163,19 @@ module Magick
|
|
|
152
163
|
save_targeting
|
|
153
164
|
|
|
154
165
|
# Update registered feature instance if it exists
|
|
155
|
-
if Magick.features.key?(name)
|
|
156
|
-
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
|
|
157
|
-
end
|
|
166
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
158
167
|
|
|
159
168
|
# Rails 8+ event
|
|
160
169
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
161
170
|
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_users, targeting_value: percentage)
|
|
162
171
|
end
|
|
172
|
+
|
|
173
|
+
true
|
|
163
174
|
end
|
|
164
175
|
|
|
165
176
|
def disable_percentage_of_users
|
|
166
177
|
disable_targeting(:percentage_users)
|
|
178
|
+
true
|
|
167
179
|
end
|
|
168
180
|
|
|
169
181
|
def enable_percentage_of_requests(percentage)
|
|
@@ -171,40 +183,46 @@ module Magick
|
|
|
171
183
|
save_targeting
|
|
172
184
|
|
|
173
185
|
# Update registered feature instance if it exists
|
|
174
|
-
if Magick.features.key?(name)
|
|
175
|
-
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup)
|
|
176
|
-
end
|
|
186
|
+
Magick.features[name].instance_variable_set(:@targeting, @targeting.dup) if Magick.features.key?(name)
|
|
177
187
|
|
|
178
188
|
# Rails 8+ event
|
|
179
189
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
180
190
|
Magick::Rails::Events.targeting_added(name, targeting_type: :percentage_requests, targeting_value: percentage)
|
|
181
191
|
end
|
|
192
|
+
|
|
193
|
+
true
|
|
182
194
|
end
|
|
183
195
|
|
|
184
196
|
def disable_percentage_of_requests
|
|
185
197
|
disable_targeting(:percentage_requests)
|
|
198
|
+
true
|
|
186
199
|
end
|
|
187
200
|
|
|
188
201
|
def enable_for_date_range(start_date, end_date)
|
|
189
202
|
enable_targeting(:date_range, { start: start_date, end: end_date })
|
|
203
|
+
true
|
|
190
204
|
end
|
|
191
205
|
|
|
192
206
|
def disable_date_range
|
|
193
207
|
disable_targeting(:date_range)
|
|
208
|
+
true
|
|
194
209
|
end
|
|
195
210
|
|
|
196
211
|
def enable_for_ip_addresses(ip_addresses)
|
|
197
212
|
enable_targeting(:ip_address, Array(ip_addresses))
|
|
213
|
+
true
|
|
198
214
|
end
|
|
199
215
|
|
|
200
216
|
def disable_ip_addresses
|
|
201
217
|
disable_targeting(:ip_address)
|
|
218
|
+
true
|
|
202
219
|
end
|
|
203
220
|
|
|
204
221
|
def enable_for_custom_attribute(attribute_name, values, operator: :equals)
|
|
205
222
|
custom_attrs = targeting[:custom_attributes] || {}
|
|
206
223
|
custom_attrs[attribute_name.to_sym] = { values: Array(values), operator: operator }
|
|
207
224
|
enable_targeting(:custom_attributes, custom_attrs)
|
|
225
|
+
true
|
|
208
226
|
end
|
|
209
227
|
|
|
210
228
|
def disable_custom_attribute(attribute_name)
|
|
@@ -215,6 +233,7 @@ module Magick
|
|
|
215
233
|
else
|
|
216
234
|
enable_targeting(:custom_attributes, custom_attrs)
|
|
217
235
|
end
|
|
236
|
+
true
|
|
218
237
|
end
|
|
219
238
|
|
|
220
239
|
def set_variants(variants)
|
|
@@ -227,6 +246,8 @@ module Magick
|
|
|
227
246
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
228
247
|
Magick::Rails::Events.variant_set(name, variants: variants_array)
|
|
229
248
|
end
|
|
249
|
+
|
|
250
|
+
true
|
|
230
251
|
end
|
|
231
252
|
|
|
232
253
|
def add_dependency(dependency_name)
|
|
@@ -237,6 +258,8 @@ module Magick
|
|
|
237
258
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
238
259
|
Magick::Rails::Events.dependency_added(name, dependency_name)
|
|
239
260
|
end
|
|
261
|
+
|
|
262
|
+
true
|
|
240
263
|
end
|
|
241
264
|
|
|
242
265
|
def remove_dependency(dependency_name)
|
|
@@ -246,6 +269,8 @@ module Magick
|
|
|
246
269
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
247
270
|
Magick::Rails::Events.dependency_removed(name, dependency_name)
|
|
248
271
|
end
|
|
272
|
+
|
|
273
|
+
true
|
|
249
274
|
end
|
|
250
275
|
|
|
251
276
|
def dependencies
|
|
@@ -257,26 +282,26 @@ module Magick
|
|
|
257
282
|
|
|
258
283
|
variants = targeting[:variants]
|
|
259
284
|
selected_variant = if variants.length == 1
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
285
|
+
variants.first[:name]
|
|
286
|
+
else
|
|
287
|
+
# Weighted random selection
|
|
288
|
+
total_weight = variants.sum { |v| v[:weight] || 0 }
|
|
289
|
+
if total_weight.zero?
|
|
290
|
+
variants.first[:name]
|
|
291
|
+
else
|
|
292
|
+
random = rand(total_weight)
|
|
293
|
+
current = 0
|
|
294
|
+
selected = nil
|
|
295
|
+
variants.each do |variant|
|
|
296
|
+
current += variant[:weight] || 0
|
|
297
|
+
if random < current
|
|
298
|
+
selected = variant[:name]
|
|
299
|
+
break
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
selected || variants.first[:name]
|
|
303
|
+
end
|
|
304
|
+
end
|
|
280
305
|
|
|
281
306
|
# Rails 8+ event
|
|
282
307
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
@@ -294,30 +319,29 @@ module Magick
|
|
|
294
319
|
adapter_registry.set(name, 'status', status)
|
|
295
320
|
adapter_registry.set(name, 'default_value', default_value)
|
|
296
321
|
adapter_registry.set(name, 'description', description) if description
|
|
322
|
+
adapter_registry.set(name, 'display_name', display_name) if display_name
|
|
297
323
|
@stored_value = value
|
|
298
324
|
|
|
299
325
|
# Update registered feature instance if it exists
|
|
300
|
-
if Magick.features.key?(name)
|
|
301
|
-
Magick.features[name].instance_variable_set(:@stored_value, value)
|
|
302
|
-
end
|
|
326
|
+
Magick.features[name].instance_variable_set(:@stored_value, value) if Magick.features.key?(name)
|
|
303
327
|
|
|
304
328
|
changes = { value: { from: old_value, to: value } }
|
|
305
329
|
|
|
306
330
|
# Audit log
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
)
|
|
314
|
-
end
|
|
331
|
+
Magick.audit_log&.log(
|
|
332
|
+
name,
|
|
333
|
+
'set_value',
|
|
334
|
+
user_id: user_id,
|
|
335
|
+
changes: changes
|
|
336
|
+
)
|
|
315
337
|
|
|
316
338
|
# Rails 8+ events
|
|
317
339
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
318
340
|
Magick::Rails::Events.feature_changed(name, changes: changes, user_id: user_id)
|
|
319
341
|
Magick::Rails::Events.audit_logged(name, action: 'set_value', user_id: user_id, changes: changes)
|
|
320
342
|
end
|
|
343
|
+
|
|
344
|
+
true
|
|
321
345
|
end
|
|
322
346
|
|
|
323
347
|
def enable(user_id: nil)
|
|
@@ -329,9 +353,9 @@ module Magick
|
|
|
329
353
|
when :boolean
|
|
330
354
|
set_value(true, user_id: user_id)
|
|
331
355
|
when :string
|
|
332
|
-
raise InvalidFeatureValueError,
|
|
356
|
+
raise InvalidFeatureValueError, 'Cannot enable string feature. Use set_value instead.'
|
|
333
357
|
when :number
|
|
334
|
-
raise InvalidFeatureValueError,
|
|
358
|
+
raise InvalidFeatureValueError, 'Cannot enable number feature. Use set_value instead.'
|
|
335
359
|
else
|
|
336
360
|
raise InvalidFeatureValueError, "Cannot enable feature of type #{type}"
|
|
337
361
|
end
|
|
@@ -340,6 +364,8 @@ module Magick
|
|
|
340
364
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
341
365
|
Magick::Rails::Events.feature_enabled_globally(name, user_id: user_id)
|
|
342
366
|
end
|
|
367
|
+
|
|
368
|
+
true
|
|
343
369
|
end
|
|
344
370
|
|
|
345
371
|
def disable(user_id: nil)
|
|
@@ -358,10 +384,15 @@ module Magick
|
|
|
358
384
|
raise InvalidFeatureValueError, "Cannot disable feature of type #{type}"
|
|
359
385
|
end
|
|
360
386
|
|
|
387
|
+
# Cascade disable: disable all features that depend on this one
|
|
388
|
+
disable_dependent_features(user_id: user_id)
|
|
389
|
+
|
|
361
390
|
# Rails 8+ event
|
|
362
391
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
363
392
|
Magick::Rails::Events.feature_disabled_globally(name, user_id: user_id)
|
|
364
393
|
end
|
|
394
|
+
|
|
395
|
+
true
|
|
365
396
|
end
|
|
366
397
|
|
|
367
398
|
def set_status(new_status)
|
|
@@ -369,6 +400,7 @@ module Magick
|
|
|
369
400
|
|
|
370
401
|
@status = new_status.to_sym
|
|
371
402
|
adapter_registry.set(name, 'status', status)
|
|
403
|
+
true
|
|
372
404
|
end
|
|
373
405
|
|
|
374
406
|
def delete
|
|
@@ -377,11 +409,13 @@ module Magick
|
|
|
377
409
|
@targeting = {}
|
|
378
410
|
# Also remove from Magick.features if registered
|
|
379
411
|
Magick.features.delete(name.to_s)
|
|
412
|
+
true
|
|
380
413
|
end
|
|
381
414
|
|
|
382
415
|
def to_h
|
|
383
416
|
{
|
|
384
417
|
name: name,
|
|
418
|
+
display_name: display_name,
|
|
385
419
|
type: type,
|
|
386
420
|
status: status,
|
|
387
421
|
value: stored_value,
|
|
@@ -409,6 +443,7 @@ module Magick
|
|
|
409
443
|
registered = Magick.features[name]
|
|
410
444
|
registered.instance_variable_set(:@stored_value, @stored_value)
|
|
411
445
|
registered.instance_variable_set(:@status, @status)
|
|
446
|
+
registered.instance_variable_set(:@display_name, @display_name)
|
|
412
447
|
registered.instance_variable_set(:@targeting, @targeting.dup)
|
|
413
448
|
end
|
|
414
449
|
self
|
|
@@ -420,6 +455,8 @@ module Magick
|
|
|
420
455
|
@stored_value = load_value_from_adapter
|
|
421
456
|
status_value = adapter_registry.get(name, 'status')
|
|
422
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
|
|
423
460
|
targeting_value = adapter_registry.get(name, 'targeting')
|
|
424
461
|
if targeting_value.is_a?(Hash)
|
|
425
462
|
# Normalize keys to symbols and handle nested structures
|
|
@@ -438,7 +475,7 @@ module Magick
|
|
|
438
475
|
|
|
439
476
|
case type
|
|
440
477
|
when :boolean
|
|
441
|
-
|
|
478
|
+
[true, 'true', 1].include?(value)
|
|
442
479
|
when :string
|
|
443
480
|
value.to_s
|
|
444
481
|
when :number
|
|
@@ -588,6 +625,73 @@ module Magick
|
|
|
588
625
|
(hash_value % 100) < percentage
|
|
589
626
|
end
|
|
590
627
|
|
|
628
|
+
def extract_context_from_object(object)
|
|
629
|
+
context = {}
|
|
630
|
+
|
|
631
|
+
# Handle hash/struct-like objects
|
|
632
|
+
if object.is_a?(Hash)
|
|
633
|
+
context[:user_id] = object[:user_id] || object['user_id'] || object[:id] || object['id']
|
|
634
|
+
context[:group] = object[:group] || object['group']
|
|
635
|
+
context[:role] = object[:role] || object['role']
|
|
636
|
+
context[:ip_address] = object[:ip_address] || object['ip_address']
|
|
637
|
+
# Include all other attributes for custom attribute matching
|
|
638
|
+
object.each do |key, value|
|
|
639
|
+
next if %i[user_id id group role ip_address].include?(key.to_sym)
|
|
640
|
+
next if %w[user_id id group role ip_address].include?(key.to_s)
|
|
641
|
+
|
|
642
|
+
context[key.to_sym] = value
|
|
643
|
+
end
|
|
644
|
+
# Handle ActiveRecord-like objects (respond to methods)
|
|
645
|
+
elsif object.respond_to?(:id) || object.respond_to?(:user_id)
|
|
646
|
+
context[:user_id] = object.respond_to?(:user_id) ? object.user_id : object.id
|
|
647
|
+
context[:group] = object.group if object.respond_to?(:group)
|
|
648
|
+
context[:role] = object.role if object.respond_to?(:role)
|
|
649
|
+
context[:ip_address] = object.ip_address if object.respond_to?(:ip_address)
|
|
650
|
+
|
|
651
|
+
# For ActiveRecord objects, include all attributes
|
|
652
|
+
if object.respond_to?(:attributes)
|
|
653
|
+
object.attributes.each do |key, value|
|
|
654
|
+
next if %w[id user_id group role ip_address].include?(key.to_s)
|
|
655
|
+
|
|
656
|
+
context[key.to_sym] = value
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
# Handle simple values (like user_id directly)
|
|
660
|
+
elsif object.respond_to?(:to_i) && object.to_i.to_s == object.to_s
|
|
661
|
+
context[:user_id] = object.to_i
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
context
|
|
665
|
+
end
|
|
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
|
+
|
|
591
695
|
def enable_targeting(type, value)
|
|
592
696
|
@targeting[type] ||= []
|
|
593
697
|
@targeting[type] << value.to_s unless @targeting[type].include?(value.to_s)
|
|
@@ -597,6 +701,8 @@ module Magick
|
|
|
597
701
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
598
702
|
Magick::Rails::Events.targeting_added(name, targeting_type: type, targeting_value: value)
|
|
599
703
|
end
|
|
704
|
+
|
|
705
|
+
true
|
|
600
706
|
end
|
|
601
707
|
|
|
602
708
|
def disable_targeting(type, value = nil)
|
|
@@ -611,14 +717,16 @@ module Magick
|
|
|
611
717
|
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
612
718
|
Magick::Rails::Events.targeting_removed(name, targeting_type: type, targeting_value: value)
|
|
613
719
|
end
|
|
720
|
+
|
|
721
|
+
true
|
|
614
722
|
end
|
|
615
723
|
|
|
616
724
|
def save_targeting
|
|
617
725
|
adapter_registry.set(name, 'targeting', targeting)
|
|
618
726
|
# Update the feature in Magick.features if it's registered
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
727
|
+
return unless Magick.features.key?(name)
|
|
728
|
+
|
|
729
|
+
Magick.features[name].instance_variable_set(:@targeting, targeting.dup)
|
|
622
730
|
end
|
|
623
731
|
|
|
624
732
|
def default_for_type
|
|
@@ -643,22 +751,30 @@ module Magick
|
|
|
643
751
|
def validate_default_value!
|
|
644
752
|
case type
|
|
645
753
|
when :boolean
|
|
646
|
-
|
|
754
|
+
unless [true, false].include?(default_value)
|
|
755
|
+
raise InvalidFeatureValueError, 'Default value must be boolean for type :boolean'
|
|
756
|
+
end
|
|
647
757
|
when :string
|
|
648
|
-
|
|
758
|
+
unless default_value.is_a?(String)
|
|
759
|
+
raise InvalidFeatureValueError,
|
|
760
|
+
'Default value must be a string for type :string'
|
|
761
|
+
end
|
|
649
762
|
when :number
|
|
650
|
-
|
|
763
|
+
unless default_value.is_a?(Numeric)
|
|
764
|
+
raise InvalidFeatureValueError,
|
|
765
|
+
'Default value must be numeric for type :number'
|
|
766
|
+
end
|
|
651
767
|
end
|
|
652
768
|
end
|
|
653
769
|
|
|
654
770
|
def validate_value!(value)
|
|
655
771
|
case type
|
|
656
772
|
when :boolean
|
|
657
|
-
raise InvalidFeatureValueError,
|
|
773
|
+
raise InvalidFeatureValueError, 'Value must be boolean for type :boolean' unless [true, false].include?(value)
|
|
658
774
|
when :string
|
|
659
|
-
raise InvalidFeatureValueError,
|
|
775
|
+
raise InvalidFeatureValueError, 'Value must be a string for type :string' unless value.is_a?(String)
|
|
660
776
|
when :number
|
|
661
|
-
raise InvalidFeatureValueError,
|
|
777
|
+
raise InvalidFeatureValueError, 'Value must be numeric for type :number' unless value.is_a?(Numeric)
|
|
662
778
|
end
|
|
663
779
|
end
|
|
664
780
|
end
|
|
@@ -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
|
|
@@ -20,7 +20,7 @@ module Magick
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
# Subscribe to a specific event
|
|
23
|
-
def subscribe_to(event_name
|
|
23
|
+
def subscribe_to(event_name)
|
|
24
24
|
return unless Events.rails81?
|
|
25
25
|
|
|
26
26
|
full_event_name = Events::EVENTS[event_name] || event_name.to_s
|
|
@@ -44,7 +44,7 @@ module Magick
|
|
|
44
44
|
# Default log subscriber for Magick events
|
|
45
45
|
class LogSubscriber
|
|
46
46
|
def emit(event)
|
|
47
|
-
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(
|
|
47
|
+
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(' ')
|
|
48
48
|
source_location = event[:source_location]
|
|
49
49
|
log = "[#{event[:name]}] #{payload}"
|
|
50
50
|
log += " at #{source_location[:filepath]}:#{source_location[:lineno]}" if source_location
|
data/lib/magick/rails/railtie.rb
CHANGED
|
@@ -43,20 +43,22 @@ if defined?(Rails)
|
|
|
43
43
|
else
|
|
44
44
|
# Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
|
|
45
45
|
initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
|
|
46
|
-
if File.exist?(initializer_file)
|
|
46
|
+
if File.exist?(initializer_file) && !defined?(Magick::Rails::FeaturesLoaded)
|
|
47
47
|
# Only load if not already loaded (Rails may have already loaded it)
|
|
48
|
-
load initializer_file
|
|
48
|
+
load initializer_file
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
|
-
|
|
51
|
+
begin
|
|
52
|
+
Magick::Rails.const_set(:FeaturesLoaded, true)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
52
56
|
end
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
# Preload features in request store
|
|
56
60
|
config.to_prepare do
|
|
57
|
-
if defined?(RequestStore)
|
|
58
|
-
RequestStore.store[:magick_features] ||= {}
|
|
59
|
-
end
|
|
61
|
+
RequestStore.store[:magick_features] ||= {} if defined?(RequestStore)
|
|
60
62
|
end
|
|
61
63
|
end
|
|
62
64
|
end
|
data/lib/magick/rails.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Magick
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
module ClassMethods
|
|
10
|
-
def with_feature_enabled(feature_name
|
|
10
|
+
def with_feature_enabled(feature_name)
|
|
11
11
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
12
12
|
original_value = feature.value
|
|
13
13
|
feature.set_value(true)
|
|
@@ -16,7 +16,7 @@ module Magick
|
|
|
16
16
|
feature.set_value(original_value)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def with_feature_disabled(feature_name
|
|
19
|
+
def with_feature_disabled(feature_name)
|
|
20
20
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
21
21
|
original_value = feature.value
|
|
22
22
|
feature.set_value(false)
|
|
@@ -25,7 +25,7 @@ module Magick
|
|
|
25
25
|
feature.set_value(original_value)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def with_feature_value(feature_name, value
|
|
28
|
+
def with_feature_value(feature_name, value)
|
|
29
29
|
feature = Magick.features[feature_name.to_s] || Magick[feature_name]
|
|
30
30
|
original_value = feature.value
|
|
31
31
|
feature.set_value(value)
|
data/lib/magick/version.rb
CHANGED
data/lib/magick/versioning.rb
CHANGED
|
@@ -61,17 +61,15 @@ module Magick
|
|
|
61
61
|
feature.set_status(feature_data[:status]) if feature_data[:status]
|
|
62
62
|
|
|
63
63
|
# Restore targeting
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
feature.enable_for_role(value)
|
|
74
|
-
end
|
|
64
|
+
feature_data[:targeting]&.each do |type, values|
|
|
65
|
+
Array(values).each do |value|
|
|
66
|
+
case type.to_sym
|
|
67
|
+
when :user
|
|
68
|
+
feature.enable_for_user(value)
|
|
69
|
+
when :group
|
|
70
|
+
feature.enable_for_group(value)
|
|
71
|
+
when :role
|
|
72
|
+
feature.enable_for_role(value)
|
|
75
73
|
end
|
|
76
74
|
end
|
|
77
75
|
end
|
data/lib/magick.rb
CHANGED
|
@@ -39,7 +39,7 @@ module Magick
|
|
|
39
39
|
# Support both old style and new DSL style
|
|
40
40
|
return unless block_given?
|
|
41
41
|
|
|
42
|
-
if block.arity
|
|
42
|
+
if block.arity.zero?
|
|
43
43
|
# New DSL style
|
|
44
44
|
ConfigDSL.configure(&block)
|
|
45
45
|
else
|
|
@@ -68,6 +68,15 @@ module Magick
|
|
|
68
68
|
feature.enabled?(context)
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
def enabled_for?(feature_name, object, **additional_context)
|
|
72
|
+
feature = features[feature_name.to_s] || self[feature_name]
|
|
73
|
+
feature.enabled_for?(object, **additional_context)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def disabled_for?(feature_name, object, **additional_context)
|
|
77
|
+
!enabled_for?(feature_name, object, **additional_context)
|
|
78
|
+
end
|
|
79
|
+
|
|
71
80
|
# Reload a feature from the adapter (useful when feature is changed externally)
|
|
72
81
|
def reload_feature(feature_name)
|
|
73
82
|
feature = features[feature_name.to_s] || self[feature_name]
|
|
@@ -82,7 +91,7 @@ module Magick
|
|
|
82
91
|
features.key?(feature_name.to_s) || (adapter_registry || default_adapter_registry).exists?(feature_name)
|
|
83
92
|
end
|
|
84
93
|
|
|
85
|
-
def bulk_enable(feature_names,
|
|
94
|
+
def bulk_enable(feature_names, _context = {})
|
|
86
95
|
feature_names.map do |name|
|
|
87
96
|
feature = features[name.to_s] || self[name]
|
|
88
97
|
feature.set_value(true) if feature.type == :boolean
|
|
@@ -90,7 +99,7 @@ module Magick
|
|
|
90
99
|
end
|
|
91
100
|
end
|
|
92
101
|
|
|
93
|
-
def bulk_disable(feature_names,
|
|
102
|
+
def bulk_disable(feature_names, _context = {})
|
|
94
103
|
feature_names.map do |name|
|
|
95
104
|
feature = features[name.to_s] || self[name]
|
|
96
105
|
feature.set_value(false) if feature.type == :boolean
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: magick-feature-flags
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Lobanov
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-11-
|
|
11
|
+
date: 2025-11-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rspec
|