magick-feature-flags 0.7.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 +21 -0
- data/README.md +363 -0
- data/lib/generators/magick/install/install_generator.rb +19 -0
- data/lib/generators/magick/install/templates/README +25 -0
- data/lib/generators/magick/install/templates/magick.rb +32 -0
- data/lib/magick/adapters/base.rb +27 -0
- data/lib/magick/adapters/memory.rb +113 -0
- data/lib/magick/adapters/redis.rb +97 -0
- data/lib/magick/adapters/registry.rb +133 -0
- data/lib/magick/audit_log.rb +65 -0
- data/lib/magick/circuit_breaker.rb +65 -0
- data/lib/magick/config.rb +179 -0
- data/lib/magick/dsl.rb +80 -0
- data/lib/magick/errors.rb +9 -0
- data/lib/magick/export_import.rb +82 -0
- data/lib/magick/feature.rb +665 -0
- data/lib/magick/feature_dependency.rb +28 -0
- data/lib/magick/feature_variant.rb +17 -0
- data/lib/magick/performance_metrics.rb +76 -0
- data/lib/magick/rails/event_subscriber.rb +55 -0
- data/lib/magick/rails/events.rb +236 -0
- data/lib/magick/rails/railtie.rb +94 -0
- data/lib/magick/rails.rb +7 -0
- data/lib/magick/targeting/base.rb +11 -0
- data/lib/magick/targeting/complex.rb +27 -0
- data/lib/magick/targeting/custom_attribute.rb +35 -0
- data/lib/magick/targeting/date_range.rb +17 -0
- data/lib/magick/targeting/group.rb +15 -0
- data/lib/magick/targeting/ip_address.rb +22 -0
- data/lib/magick/targeting/percentage.rb +24 -0
- data/lib/magick/targeting/request_percentage.rb +15 -0
- data/lib/magick/targeting/role.rb +15 -0
- data/lib/magick/targeting/user.rb +15 -0
- data/lib/magick/testing_helpers.rb +45 -0
- data/lib/magick/version.rb +5 -0
- data/lib/magick/versioning.rb +98 -0
- data/lib/magick.rb +143 -0
- metadata +123 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
class PerformanceMetrics
|
|
5
|
+
class Metric
|
|
6
|
+
attr_reader :feature_name, :operation, :duration, :timestamp, :success
|
|
7
|
+
|
|
8
|
+
def initialize(feature_name, operation, duration, success: true)
|
|
9
|
+
@feature_name = feature_name.to_s
|
|
10
|
+
@operation = operation.to_s
|
|
11
|
+
@duration = duration
|
|
12
|
+
@timestamp = Time.now
|
|
13
|
+
@success = success
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{
|
|
18
|
+
feature_name: feature_name,
|
|
19
|
+
operation: operation,
|
|
20
|
+
duration: duration,
|
|
21
|
+
timestamp: timestamp.iso8601,
|
|
22
|
+
success: success
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@metrics = []
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@usage_count = Hash.new(0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record(feature_name, operation, duration, success: true)
|
|
34
|
+
metric = Metric.new(feature_name, operation, duration, success: success)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@metrics << metric
|
|
37
|
+
@usage_count[feature_name.to_s] += 1
|
|
38
|
+
# Keep only last 1000 metrics
|
|
39
|
+
@metrics.shift if @metrics.length > 1000
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Rails 8+ event for usage tracking
|
|
43
|
+
if defined?(Magick::Rails::Events) && Magick::Rails::Events.rails8?
|
|
44
|
+
Magick::Rails::Events.usage_tracked(feature_name, operation: operation, duration: duration, success: success)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
metric
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def average_duration(feature_name: nil, operation: nil)
|
|
51
|
+
filtered = @metrics.select do |m|
|
|
52
|
+
(feature_name.nil? || m.feature_name == feature_name.to_s) &&
|
|
53
|
+
(operation.nil? || m.operation == operation.to_s) &&
|
|
54
|
+
m.success
|
|
55
|
+
end
|
|
56
|
+
return 0.0 if filtered.empty?
|
|
57
|
+
|
|
58
|
+
filtered.sum(&:duration) / filtered.length.to_f
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def usage_count(feature_name)
|
|
62
|
+
@usage_count[feature_name.to_s] || 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def most_used_features(limit: 10)
|
|
66
|
+
@usage_count.sort_by { |_name, count| -count }.first(limit).to_h
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def clear!
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
@metrics.clear
|
|
72
|
+
@usage_count.clear
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Rails
|
|
5
|
+
# Example event subscriber for Rails 8.1+ structured events
|
|
6
|
+
# Users can create custom subscribers to handle Magick events
|
|
7
|
+
class EventSubscriber
|
|
8
|
+
def initialize
|
|
9
|
+
@subscribed = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Subscribe to all Magick events
|
|
13
|
+
def subscribe_to_all
|
|
14
|
+
return unless Events.rails81?
|
|
15
|
+
|
|
16
|
+
Events::EVENTS.each_value do |event_name|
|
|
17
|
+
subscribe_to(event_name)
|
|
18
|
+
end
|
|
19
|
+
@subscribed = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Subscribe to a specific event
|
|
23
|
+
def subscribe_to(event_name, &block)
|
|
24
|
+
return unless Events.rails81?
|
|
25
|
+
|
|
26
|
+
full_event_name = Events::EVENTS[event_name] || event_name.to_s
|
|
27
|
+
Rails.event.subscribe(full_event_name, self)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Implement the emit method required by Rails 8.1 event system
|
|
31
|
+
def emit(event)
|
|
32
|
+
# event is a hash with :name, :payload, :source_location, :tags, :context
|
|
33
|
+
handle_event(event)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def handle_event(event)
|
|
39
|
+
# Default handler - users can override
|
|
40
|
+
Rails.logger&.info "Magick Event: #{event[:name]} - #{event[:payload].inspect}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Default log subscriber for Magick events
|
|
45
|
+
class LogSubscriber
|
|
46
|
+
def emit(event)
|
|
47
|
+
payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
48
|
+
source_location = event[:source_location]
|
|
49
|
+
log = "[#{event[:name]}] #{payload}"
|
|
50
|
+
log += " at #{source_location[:filepath]}:#{source_location[:lineno]}" if source_location
|
|
51
|
+
Rails.logger&.info(log)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Rails
|
|
5
|
+
module Events
|
|
6
|
+
# Check if Rails 8.1+ structured events are available
|
|
7
|
+
def self.rails81?
|
|
8
|
+
defined?(Rails) && Rails.respond_to?(:event) && Rails.event.respond_to?(:notify)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Event names (using Rails 8.1 structured event format)
|
|
12
|
+
EVENT_PREFIX = 'magick.feature_flag'
|
|
13
|
+
|
|
14
|
+
EVENTS = {
|
|
15
|
+
changed: "#{EVENT_PREFIX}.changed",
|
|
16
|
+
enabled: "#{EVENT_PREFIX}.enabled",
|
|
17
|
+
disabled: "#{EVENT_PREFIX}.disabled",
|
|
18
|
+
enabled_globally: "#{EVENT_PREFIX}.enabled_globally",
|
|
19
|
+
disabled_globally: "#{EVENT_PREFIX}.disabled_globally",
|
|
20
|
+
dependency_added: "#{EVENT_PREFIX}.dependency_added",
|
|
21
|
+
dependency_removed: "#{EVENT_PREFIX}.dependency_removed",
|
|
22
|
+
variant_set: "#{EVENT_PREFIX}.variant_set",
|
|
23
|
+
variant_selected: "#{EVENT_PREFIX}.variant_selected",
|
|
24
|
+
targeting_added: "#{EVENT_PREFIX}.targeting_added",
|
|
25
|
+
targeting_removed: "#{EVENT_PREFIX}.targeting_removed",
|
|
26
|
+
version_saved: "#{EVENT_PREFIX}.version_saved",
|
|
27
|
+
rollback: "#{EVENT_PREFIX}.rollback",
|
|
28
|
+
exported: "#{EVENT_PREFIX}.exported",
|
|
29
|
+
imported: "#{EVENT_PREFIX}.imported",
|
|
30
|
+
audit_logged: "#{EVENT_PREFIX}.audit_logged",
|
|
31
|
+
usage_tracked: "#{EVENT_PREFIX}.usage_tracked",
|
|
32
|
+
deprecated_warning: "#{EVENT_PREFIX}.deprecated_warning"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
def self.notify(event_name, payload = {})
|
|
36
|
+
return unless rails81?
|
|
37
|
+
|
|
38
|
+
event_name_str = EVENTS[event_name] || event_name.to_s
|
|
39
|
+
Rails.event.notify(event_name_str, payload)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Backward compatibility alias
|
|
43
|
+
def self.rails8?
|
|
44
|
+
rails81?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Feature flag changed (value, status, etc.)
|
|
48
|
+
def self.feature_changed(feature_name, changes:, user_id: nil, **metadata)
|
|
49
|
+
notify(:changed, {
|
|
50
|
+
feature_name: feature_name.to_s,
|
|
51
|
+
changes: changes,
|
|
52
|
+
user_id: user_id,
|
|
53
|
+
timestamp: Time.now.iso8601,
|
|
54
|
+
**metadata
|
|
55
|
+
})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Feature enabled
|
|
59
|
+
def self.feature_enabled(feature_name, context: {}, **metadata)
|
|
60
|
+
notify(:enabled, {
|
|
61
|
+
feature_name: feature_name.to_s,
|
|
62
|
+
context: context,
|
|
63
|
+
timestamp: Time.now.iso8601,
|
|
64
|
+
**metadata
|
|
65
|
+
})
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Feature disabled
|
|
69
|
+
def self.feature_disabled(feature_name, context: {}, **metadata)
|
|
70
|
+
notify(:disabled, {
|
|
71
|
+
feature_name: feature_name.to_s,
|
|
72
|
+
context: context,
|
|
73
|
+
timestamp: Time.now.iso8601,
|
|
74
|
+
**metadata
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Feature enabled globally (no targeting)
|
|
79
|
+
def self.feature_enabled_globally(feature_name, user_id: nil, **metadata)
|
|
80
|
+
notify(:enabled_globally, {
|
|
81
|
+
feature_name: feature_name.to_s,
|
|
82
|
+
user_id: user_id,
|
|
83
|
+
timestamp: Time.now.iso8601,
|
|
84
|
+
**metadata
|
|
85
|
+
})
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Feature disabled globally (no targeting)
|
|
89
|
+
def self.feature_disabled_globally(feature_name, user_id: nil, **metadata)
|
|
90
|
+
notify(:disabled_globally, {
|
|
91
|
+
feature_name: feature_name.to_s,
|
|
92
|
+
user_id: user_id,
|
|
93
|
+
timestamp: Time.now.iso8601,
|
|
94
|
+
**metadata
|
|
95
|
+
})
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Dependency added
|
|
99
|
+
def self.dependency_added(feature_name, dependency_name, **metadata)
|
|
100
|
+
notify(:dependency_added, {
|
|
101
|
+
feature_name: feature_name.to_s,
|
|
102
|
+
dependency_name: dependency_name.to_s,
|
|
103
|
+
timestamp: Time.now.iso8601,
|
|
104
|
+
**metadata
|
|
105
|
+
})
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Dependency removed
|
|
109
|
+
def self.dependency_removed(feature_name, dependency_name, **metadata)
|
|
110
|
+
notify(:dependency_removed, {
|
|
111
|
+
feature_name: feature_name.to_s,
|
|
112
|
+
dependency_name: dependency_name.to_s,
|
|
113
|
+
timestamp: Time.now.iso8601,
|
|
114
|
+
**metadata
|
|
115
|
+
})
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Variants set
|
|
119
|
+
def self.variant_set(feature_name, variants:, **metadata)
|
|
120
|
+
notify(:variant_set, {
|
|
121
|
+
feature_name: feature_name.to_s,
|
|
122
|
+
variants: variants.is_a?(Array) ? variants.map(&:to_h) : variants,
|
|
123
|
+
timestamp: Time.now.iso8601,
|
|
124
|
+
**metadata
|
|
125
|
+
})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Variant selected
|
|
129
|
+
def self.variant_selected(feature_name, variant_name:, context: {}, **metadata)
|
|
130
|
+
notify(:variant_selected, {
|
|
131
|
+
feature_name: feature_name.to_s,
|
|
132
|
+
variant_name: variant_name.to_s,
|
|
133
|
+
context: context,
|
|
134
|
+
timestamp: Time.now.iso8601,
|
|
135
|
+
**metadata
|
|
136
|
+
})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Targeting added
|
|
140
|
+
def self.targeting_added(feature_name, targeting_type:, targeting_value:, **metadata)
|
|
141
|
+
notify(:targeting_added, {
|
|
142
|
+
feature_name: feature_name.to_s,
|
|
143
|
+
targeting_type: targeting_type.to_s,
|
|
144
|
+
targeting_value: targeting_value,
|
|
145
|
+
timestamp: Time.now.iso8601,
|
|
146
|
+
**metadata
|
|
147
|
+
})
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Targeting removed
|
|
151
|
+
def self.targeting_removed(feature_name, targeting_type:, targeting_value: nil, **metadata)
|
|
152
|
+
notify(:targeting_removed, {
|
|
153
|
+
feature_name: feature_name.to_s,
|
|
154
|
+
targeting_type: targeting_type.to_s,
|
|
155
|
+
targeting_value: targeting_value,
|
|
156
|
+
timestamp: Time.now.iso8601,
|
|
157
|
+
**metadata
|
|
158
|
+
})
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Version saved
|
|
162
|
+
def self.version_saved(feature_name, version:, created_by: nil, **metadata)
|
|
163
|
+
notify(:version_saved, {
|
|
164
|
+
feature_name: feature_name.to_s,
|
|
165
|
+
version: version,
|
|
166
|
+
created_by: created_by,
|
|
167
|
+
timestamp: Time.now.iso8601,
|
|
168
|
+
**metadata
|
|
169
|
+
})
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Rollback performed
|
|
173
|
+
def self.rollback(feature_name, version:, **metadata)
|
|
174
|
+
notify(:rollback, {
|
|
175
|
+
feature_name: feature_name.to_s,
|
|
176
|
+
version: version,
|
|
177
|
+
timestamp: Time.now.iso8601,
|
|
178
|
+
**metadata
|
|
179
|
+
})
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Export performed
|
|
183
|
+
def self.exported(format:, feature_count:, **metadata)
|
|
184
|
+
notify(:exported, {
|
|
185
|
+
format: format.to_s,
|
|
186
|
+
feature_count: feature_count,
|
|
187
|
+
timestamp: Time.now.iso8601,
|
|
188
|
+
**metadata
|
|
189
|
+
})
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Import performed
|
|
193
|
+
def self.imported(format:, feature_count:, **metadata)
|
|
194
|
+
notify(:imported, {
|
|
195
|
+
format: format.to_s,
|
|
196
|
+
feature_count: feature_count,
|
|
197
|
+
timestamp: Time.now.iso8601,
|
|
198
|
+
**metadata
|
|
199
|
+
})
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Audit log entry created
|
|
203
|
+
def self.audit_logged(feature_name, action:, user_id: nil, changes: {}, **metadata)
|
|
204
|
+
notify(:audit_logged, {
|
|
205
|
+
feature_name: feature_name.to_s,
|
|
206
|
+
action: action.to_s,
|
|
207
|
+
user_id: user_id,
|
|
208
|
+
changes: changes,
|
|
209
|
+
timestamp: Time.now.iso8601,
|
|
210
|
+
**metadata
|
|
211
|
+
})
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Usage tracked
|
|
215
|
+
def self.usage_tracked(feature_name, operation:, duration:, success: true, **metadata)
|
|
216
|
+
notify(:usage_tracked, {
|
|
217
|
+
feature_name: feature_name.to_s,
|
|
218
|
+
operation: operation.to_s,
|
|
219
|
+
duration: duration,
|
|
220
|
+
success: success,
|
|
221
|
+
timestamp: Time.now.iso8601,
|
|
222
|
+
**metadata
|
|
223
|
+
})
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Deprecation warning
|
|
227
|
+
def self.deprecated_warning(feature_name, **metadata)
|
|
228
|
+
notify(:deprecated_warning, {
|
|
229
|
+
feature_name: feature_name.to_s,
|
|
230
|
+
timestamp: Time.now.iso8601,
|
|
231
|
+
**metadata
|
|
232
|
+
})
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
if defined?(Rails)
|
|
4
|
+
require 'magick'
|
|
5
|
+
require 'magick/dsl'
|
|
6
|
+
require_relative 'events'
|
|
7
|
+
|
|
8
|
+
module Magick
|
|
9
|
+
module Rails
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
# Make DSL available early so it works in config/initializers/features.rb
|
|
12
|
+
initializer 'magick.dsl', before: :load_config_initializers do
|
|
13
|
+
# Ensure DSL is available globally for initializers
|
|
14
|
+
Object.class_eval { include Magick::DSL } unless Object.included_modules.include?(Magick::DSL)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer 'magick.configure' do |app|
|
|
18
|
+
# Configure Magick with Rails-specific settings
|
|
19
|
+
Magick.configure do |config|
|
|
20
|
+
# Use Redis if available, otherwise fall back to memory
|
|
21
|
+
if defined?(Redis)
|
|
22
|
+
begin
|
|
23
|
+
redis_url = app.config.respond_to?(:redis_url) ? app.config.redis_url : nil
|
|
24
|
+
redis_client = redis_url ? ::Redis.new(url: redis_url) : ::Redis.new
|
|
25
|
+
memory_adapter = Adapters::Memory.new
|
|
26
|
+
redis_adapter = Adapters::Redis.new(redis_client)
|
|
27
|
+
config.adapter_registry = Adapters::Registry.new(memory_adapter, redis_adapter)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Rails.logger&.warn "Magick: Failed to initialize Redis adapter: #{e.message}. Using memory-only adapter."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Load features from DSL file if it exists
|
|
35
|
+
# Supports both config/features.rb and config/initializers/features.rb
|
|
36
|
+
config.after_initialize do
|
|
37
|
+
# Try config/features.rb first (recommended location)
|
|
38
|
+
features_file = Rails.root.join('config', 'features.rb')
|
|
39
|
+
if File.exist?(features_file)
|
|
40
|
+
load features_file
|
|
41
|
+
else
|
|
42
|
+
# Fallback to config/initializers/features.rb (already loaded by Rails, but check anyway)
|
|
43
|
+
initializer_file = Rails.root.join('config', 'initializers', 'features.rb')
|
|
44
|
+
if File.exist?(initializer_file)
|
|
45
|
+
# Only load if not already loaded (Rails may have already loaded it)
|
|
46
|
+
load initializer_file unless defined?(Magick::Rails::FeaturesLoaded)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
Magick::Rails.const_set(:FeaturesLoaded, true) rescue nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Preload features in request store
|
|
54
|
+
config.to_prepare do
|
|
55
|
+
if defined?(RequestStore)
|
|
56
|
+
RequestStore.store[:magick_features] ||= {}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Request store integration
|
|
63
|
+
module RequestStoreIntegration
|
|
64
|
+
def self.included(base)
|
|
65
|
+
base.extend(ClassMethods)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
module ClassMethods
|
|
69
|
+
def enabled?(feature_name, context = {})
|
|
70
|
+
# Check request store cache first
|
|
71
|
+
if defined?(RequestStore)
|
|
72
|
+
cache_key = "#{feature_name}:#{context.hash}"
|
|
73
|
+
cached = RequestStore.store[:magick_features]&.[](cache_key)
|
|
74
|
+
return cached unless cached.nil?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Check feature
|
|
78
|
+
result = super(feature_name, context)
|
|
79
|
+
|
|
80
|
+
# Cache in request store
|
|
81
|
+
if defined?(RequestStore)
|
|
82
|
+
RequestStore.store[:magick_features] ||= {}
|
|
83
|
+
RequestStore.store[:magick_features][cache_key] = result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Extend Magick module with request store integration
|
|
93
|
+
Magick.extend(Magick::Rails::RequestStoreIntegration)
|
|
94
|
+
end
|
data/lib/magick/rails.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Targeting
|
|
5
|
+
class Complex < Base
|
|
6
|
+
def initialize(conditions, operator: :and)
|
|
7
|
+
@conditions = Array(conditions)
|
|
8
|
+
@operator = operator.to_sym
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def matches?(context)
|
|
12
|
+
return false if @conditions.empty?
|
|
13
|
+
|
|
14
|
+
results = @conditions.map { |condition| condition.matches?(context) }
|
|
15
|
+
|
|
16
|
+
case @operator
|
|
17
|
+
when :and, :all
|
|
18
|
+
results.all?
|
|
19
|
+
when :or, :any
|
|
20
|
+
results.any?
|
|
21
|
+
else
|
|
22
|
+
false
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Targeting
|
|
5
|
+
class CustomAttribute < Base
|
|
6
|
+
def initialize(attribute_name, values, operator: :equals)
|
|
7
|
+
@attribute_name = attribute_name.to_sym
|
|
8
|
+
@values = Array(values)
|
|
9
|
+
@operator = operator.to_sym
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def matches?(context)
|
|
13
|
+
context_value = context[@attribute_name] || context[@attribute_name.to_s]
|
|
14
|
+
return false if context_value.nil?
|
|
15
|
+
|
|
16
|
+
case @operator
|
|
17
|
+
when :equals, :eq
|
|
18
|
+
@values.include?(context_value.to_s)
|
|
19
|
+
when :not_equals, :ne
|
|
20
|
+
!@values.include?(context_value.to_s)
|
|
21
|
+
when :in
|
|
22
|
+
@values.include?(context_value.to_s)
|
|
23
|
+
when :not_in
|
|
24
|
+
!@values.include?(context_value.to_s)
|
|
25
|
+
when :greater_than, :gt
|
|
26
|
+
context_value.to_f > @values.first.to_f
|
|
27
|
+
when :less_than, :lt
|
|
28
|
+
context_value.to_f < @values.first.to_f
|
|
29
|
+
else
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Magick
|
|
4
|
+
module Targeting
|
|
5
|
+
class DateRange < Base
|
|
6
|
+
def initialize(start_date, end_date)
|
|
7
|
+
@start_date = start_date.is_a?(String) ? Time.parse(start_date) : start_date
|
|
8
|
+
@end_date = end_date.is_a?(String) ? Time.parse(end_date) : end_date
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def matches?(_context)
|
|
12
|
+
now = Time.now
|
|
13
|
+
now >= @start_date && now <= @end_date
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
|
|
5
|
+
module Magick
|
|
6
|
+
module Targeting
|
|
7
|
+
class IpAddress < Base
|
|
8
|
+
def initialize(ip_addresses)
|
|
9
|
+
@ip_addresses = Array(ip_addresses).map { |ip| IPAddr.new(ip) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def matches?(context)
|
|
13
|
+
return false unless context[:ip_address]
|
|
14
|
+
|
|
15
|
+
client_ip = IPAddr.new(context[:ip_address])
|
|
16
|
+
@ip_addresses.any? { |ip| ip.include?(client_ip) }
|
|
17
|
+
rescue IPAddr::InvalidAddressError
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Magick
|
|
6
|
+
module Targeting
|
|
7
|
+
class Percentage < Base
|
|
8
|
+
def initialize(percentage, feature_name, user_id = nil)
|
|
9
|
+
@percentage = percentage.to_f
|
|
10
|
+
@feature_name = feature_name.to_s
|
|
11
|
+
@user_id = user_id&.to_s
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def matches?(context)
|
|
15
|
+
user_id = (@user_id || context[:user_id])&.to_s
|
|
16
|
+
return false unless user_id
|
|
17
|
+
|
|
18
|
+
hash = Digest::MD5.hexdigest("#{@feature_name}:#{user_id}")
|
|
19
|
+
hash_value = hash[0..7].to_i(16)
|
|
20
|
+
(hash_value % 100) < @percentage
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|