featurevisor 0.1.1
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 +722 -0
- data/bin/cli.rb +142 -0
- data/bin/commands/assess_distribution.rb +236 -0
- data/bin/commands/benchmark.rb +274 -0
- data/bin/commands/test.rb +793 -0
- data/bin/commands.rb +10 -0
- data/bin/featurevisor +18 -0
- data/lib/featurevisor/bucketer.rb +95 -0
- data/lib/featurevisor/child_instance.rb +311 -0
- data/lib/featurevisor/compare_versions.rb +126 -0
- data/lib/featurevisor/conditions.rb +152 -0
- data/lib/featurevisor/datafile_reader.rb +350 -0
- data/lib/featurevisor/emitter.rb +60 -0
- data/lib/featurevisor/evaluate.rb +818 -0
- data/lib/featurevisor/events.rb +76 -0
- data/lib/featurevisor/hooks.rb +159 -0
- data/lib/featurevisor/instance.rb +463 -0
- data/lib/featurevisor/logger.rb +150 -0
- data/lib/featurevisor/murmurhash.rb +69 -0
- data/lib/featurevisor/version.rb +3 -0
- data/lib/featurevisor.rb +17 -0
- metadata +89 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Featurevisor
|
|
6
|
+
# Conditions module for evaluating feature flags and segments
|
|
7
|
+
module Conditions
|
|
8
|
+
# Get value from context object using dot notation path
|
|
9
|
+
# @param obj [Hash] Context object
|
|
10
|
+
# @param path [String] Dot-separated path to the value
|
|
11
|
+
# @return [Object, nil] Value at the path or nil if not found
|
|
12
|
+
def self.get_value_from_context(obj, path)
|
|
13
|
+
return nil if obj.nil? || path.nil?
|
|
14
|
+
|
|
15
|
+
if path.index(".") == -1
|
|
16
|
+
return obj[path.to_sym] || obj[path]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
path.split(".").reduce(obj) { |o, i| o&.[](i.to_sym) || o&.[](i) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if a condition is matched against context
|
|
23
|
+
# @param condition [Hash] Condition to evaluate
|
|
24
|
+
# @param context [Hash] Context to evaluate against
|
|
25
|
+
# @param get_regex [Proc] Function to get regex for pattern matching
|
|
26
|
+
# @return [Boolean] True if condition matches
|
|
27
|
+
def self.condition_is_matched(condition, context, get_regex)
|
|
28
|
+
attribute = condition["attribute"] || condition[:attribute]
|
|
29
|
+
operator = condition["operator"] || condition[:operator]
|
|
30
|
+
value = condition["value"] || condition[:value]
|
|
31
|
+
regex_flags = condition["regexFlags"] || condition[:regexFlags]
|
|
32
|
+
|
|
33
|
+
context_value_from_path = get_value_from_context(context, attribute)
|
|
34
|
+
|
|
35
|
+
case operator
|
|
36
|
+
when "equals"
|
|
37
|
+
context_value_from_path == value
|
|
38
|
+
when "notEquals"
|
|
39
|
+
context_value_from_path != value
|
|
40
|
+
when "before", "after"
|
|
41
|
+
# date comparisons
|
|
42
|
+
value_in_context = context_value_from_path
|
|
43
|
+
date_in_context = value_in_context.is_a?(Date) ? value_in_context : Date.parse(value_in_context.to_s)
|
|
44
|
+
date_in_condition = value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
45
|
+
|
|
46
|
+
if operator == "before"
|
|
47
|
+
date_in_context < date_in_condition
|
|
48
|
+
else
|
|
49
|
+
date_in_context > date_in_condition
|
|
50
|
+
end
|
|
51
|
+
when "in", "notIn"
|
|
52
|
+
# in / notIn (where condition value is an array)
|
|
53
|
+
if value.is_a?(Array) && (context_value_from_path.is_a?(String) || context_value_from_path.is_a?(Numeric) || context_value_from_path.nil?)
|
|
54
|
+
# Check if the attribute key actually exists in the context
|
|
55
|
+
key_exists = context.key?(attribute.to_sym) || context.key?(attribute.to_s)
|
|
56
|
+
|
|
57
|
+
# If key doesn't exist, notIn should fail (return false), in should also fail
|
|
58
|
+
if !key_exists
|
|
59
|
+
return false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
value_in_context = context_value_from_path.to_s
|
|
63
|
+
|
|
64
|
+
if operator == "in"
|
|
65
|
+
value.include?(value_in_context)
|
|
66
|
+
else # notIn
|
|
67
|
+
!value.include?(value_in_context)
|
|
68
|
+
end
|
|
69
|
+
else
|
|
70
|
+
false
|
|
71
|
+
end
|
|
72
|
+
when "contains", "notContains", "startsWith", "endsWith", "semverEquals", "semverNotEquals", "semverGreaterThan", "semverGreaterThanOrEquals", "semverLessThan", "semverLessThanOrEquals", "matches", "notMatches"
|
|
73
|
+
# string operations
|
|
74
|
+
if context_value_from_path.is_a?(String) && value.is_a?(String)
|
|
75
|
+
value_in_context = context_value_from_path
|
|
76
|
+
|
|
77
|
+
case operator
|
|
78
|
+
when "contains"
|
|
79
|
+
value_in_context.include?(value)
|
|
80
|
+
when "notContains"
|
|
81
|
+
!value_in_context.include?(value)
|
|
82
|
+
when "startsWith"
|
|
83
|
+
value_in_context.start_with?(value)
|
|
84
|
+
when "endsWith"
|
|
85
|
+
value_in_context.end_with?(value)
|
|
86
|
+
when "semverEquals"
|
|
87
|
+
Featurevisor.compare_versions(value_in_context, value) == 0
|
|
88
|
+
when "semverNotEquals"
|
|
89
|
+
Featurevisor.compare_versions(value_in_context, value) != 0
|
|
90
|
+
when "semverGreaterThan"
|
|
91
|
+
Featurevisor.compare_versions(value_in_context, value) == 1
|
|
92
|
+
when "semverGreaterThanOrEquals"
|
|
93
|
+
Featurevisor.compare_versions(value_in_context, value) >= 0
|
|
94
|
+
when "semverLessThan"
|
|
95
|
+
Featurevisor.compare_versions(value_in_context, value) == -1
|
|
96
|
+
when "semverLessThanOrEquals"
|
|
97
|
+
Featurevisor.compare_versions(value_in_context, value) <= 0
|
|
98
|
+
when "matches"
|
|
99
|
+
regex = get_regex.call(value, regex_flags || "")
|
|
100
|
+
regex.match?(value_in_context)
|
|
101
|
+
when "notMatches"
|
|
102
|
+
regex = get_regex.call(value, regex_flags || "")
|
|
103
|
+
!regex.match?(value_in_context)
|
|
104
|
+
end
|
|
105
|
+
else
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
when "greaterThan", "greaterThanOrEquals", "lessThan", "lessThanOrEquals"
|
|
109
|
+
# numeric operations
|
|
110
|
+
if context_value_from_path.is_a?(Numeric) && value.is_a?(Numeric)
|
|
111
|
+
value_in_context = context_value_from_path
|
|
112
|
+
|
|
113
|
+
case operator
|
|
114
|
+
when "greaterThan"
|
|
115
|
+
value_in_context > value
|
|
116
|
+
when "greaterThanOrEquals"
|
|
117
|
+
value_in_context >= value
|
|
118
|
+
when "lessThan"
|
|
119
|
+
value_in_context < value
|
|
120
|
+
when "lessThanOrEquals"
|
|
121
|
+
value_in_context <= value
|
|
122
|
+
end
|
|
123
|
+
else
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
when "exists"
|
|
127
|
+
context_value_from_path != nil
|
|
128
|
+
when "notExists"
|
|
129
|
+
context_value_from_path.nil?
|
|
130
|
+
when "includes", "notIncludes"
|
|
131
|
+
# includes / notIncludes (where context value is an array)
|
|
132
|
+
if context_value_from_path.is_a?(Array) && value.is_a?(String)
|
|
133
|
+
value_in_context = context_value_from_path
|
|
134
|
+
|
|
135
|
+
if operator == "includes"
|
|
136
|
+
value_in_context.include?(value)
|
|
137
|
+
else # notIncludes
|
|
138
|
+
!value_in_context.include?(value)
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
rescue => e
|
|
147
|
+
# Log error but don't stop execution
|
|
148
|
+
warn "Error in condition evaluation: #{e.message}"
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Featurevisor
|
|
6
|
+
# DatafileReader class for reading and processing Featurevisor datafiles
|
|
7
|
+
class DatafileReader
|
|
8
|
+
attr_reader :schema_version, :revision, :segments, :features, :logger, :regex_cache
|
|
9
|
+
|
|
10
|
+
# Initialize a new DatafileReader
|
|
11
|
+
# @param options [Hash] Options hash containing datafile and logger
|
|
12
|
+
# @option options [Hash] :datafile Datafile content
|
|
13
|
+
# @option options [Logger] :logger Logger instance
|
|
14
|
+
def initialize(options)
|
|
15
|
+
datafile = options[:datafile]
|
|
16
|
+
@logger = options[:logger]
|
|
17
|
+
|
|
18
|
+
@schema_version = datafile[:schemaVersion]
|
|
19
|
+
@revision = datafile[:revision]
|
|
20
|
+
@segments = (datafile[:segments] || {}).transform_keys(&:to_sym)
|
|
21
|
+
@features = (datafile[:features] || {}).transform_keys(&:to_sym)
|
|
22
|
+
|
|
23
|
+
# Transform nested structures to use symbol keys
|
|
24
|
+
@features.each do |_key, feature|
|
|
25
|
+
if feature[:variablesSchema]
|
|
26
|
+
feature[:variablesSchema] = feature[:variablesSchema].transform_keys(&:to_sym)
|
|
27
|
+
end
|
|
28
|
+
if feature[:variations]
|
|
29
|
+
feature[:variations].each do |variation|
|
|
30
|
+
if variation[:variables]
|
|
31
|
+
variation[:variables] = variation[:variables].transform_keys(&:to_sym)
|
|
32
|
+
end
|
|
33
|
+
if variation[:variableOverrides]
|
|
34
|
+
variation[:variableOverrides] = variation[:variableOverrides].transform_keys(&:to_sym)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
if feature[:force]
|
|
39
|
+
feature[:force].each do |force_rule|
|
|
40
|
+
if force_rule[:variables]
|
|
41
|
+
force_rule[:variables] = force_rule[:variables].transform_keys(&:to_sym)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
if feature[:traffic]
|
|
46
|
+
feature[:traffic].each do |traffic_rule|
|
|
47
|
+
if traffic_rule[:variables]
|
|
48
|
+
traffic_rule[:variables] = traffic_rule[:variables].transform_keys(&:to_sym)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
@regex_cache = {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get the revision of the datafile
|
|
57
|
+
# @return [String] Revision string
|
|
58
|
+
def get_revision
|
|
59
|
+
@revision
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Get the schema version of the datafile
|
|
63
|
+
# @return [String] Schema version string
|
|
64
|
+
def get_schema_version
|
|
65
|
+
@schema_version
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get a segment by key
|
|
69
|
+
# @param segment_key [String] Segment key to retrieve
|
|
70
|
+
# @return [Hash, nil] Segment data or nil if not found
|
|
71
|
+
def get_segment(segment_key)
|
|
72
|
+
segment = @segments[segment_key.to_sym] || @segments[segment_key]
|
|
73
|
+
|
|
74
|
+
return nil unless segment
|
|
75
|
+
|
|
76
|
+
segment[:conditions] = parse_conditions_if_stringified(segment[:conditions])
|
|
77
|
+
segment
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get all feature keys
|
|
81
|
+
# @return [Array<Symbol>] Array of feature keys
|
|
82
|
+
def get_feature_keys
|
|
83
|
+
@features.keys
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get a feature by key
|
|
87
|
+
# @param feature_key [String] Feature key to retrieve
|
|
88
|
+
# @return [Hash, nil] Feature data or nil if not found
|
|
89
|
+
def get_feature(feature_key)
|
|
90
|
+
@features[feature_key.to_sym] || @features[feature_key]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get variable keys for a feature
|
|
94
|
+
# @param feature_key [String] Feature key
|
|
95
|
+
# @return [Array<String>] Array of variable keys
|
|
96
|
+
def get_variable_keys(feature_key)
|
|
97
|
+
feature = get_feature(feature_key)
|
|
98
|
+
|
|
99
|
+
return [] unless feature
|
|
100
|
+
|
|
101
|
+
(feature[:variablesSchema] || {}).keys
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if a feature has variations
|
|
105
|
+
# @param feature_key [String] Feature key
|
|
106
|
+
# @return [Boolean] True if feature has variations
|
|
107
|
+
def has_variations?(feature_key)
|
|
108
|
+
feature = get_feature(feature_key)
|
|
109
|
+
|
|
110
|
+
return false unless feature
|
|
111
|
+
|
|
112
|
+
feature[:variations].is_a?(Array) && feature[:variations].size > 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get a regex object with caching
|
|
116
|
+
# @param regex_string [String] Regex pattern string
|
|
117
|
+
# @param regex_flags [String] Regex flags (optional)
|
|
118
|
+
# @return [Regexp] Compiled regex object
|
|
119
|
+
def get_regex(regex_string, regex_flags = "")
|
|
120
|
+
flags = regex_flags || ""
|
|
121
|
+
cache_key = "#{regex_string}-#{flags}"
|
|
122
|
+
|
|
123
|
+
return @regex_cache[cache_key] if @regex_cache[cache_key]
|
|
124
|
+
|
|
125
|
+
regex = Regexp.new(regex_string, flags)
|
|
126
|
+
@regex_cache[cache_key] = regex
|
|
127
|
+
@regex_cache[cache_key]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if all conditions are matched against context
|
|
131
|
+
# @param conditions [Array<Hash>, Hash, String] Conditions to evaluate
|
|
132
|
+
# @param context [Hash] Context to evaluate against
|
|
133
|
+
# @return [Boolean] True if all conditions match
|
|
134
|
+
def all_conditions_are_matched(conditions, context)
|
|
135
|
+
if conditions.is_a?(String)
|
|
136
|
+
return true if conditions == "*"
|
|
137
|
+
return false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
get_regex_proc = ->(regex_string, regex_flags) { get_regex(regex_string, regex_flags) }
|
|
141
|
+
|
|
142
|
+
if conditions.is_a?(Hash) && (conditions[:attribute] || conditions["attribute"])
|
|
143
|
+
begin
|
|
144
|
+
result = Conditions.condition_is_matched(conditions, context, get_regex_proc)
|
|
145
|
+
return result
|
|
146
|
+
rescue => e
|
|
147
|
+
@logger.warn("Error in condition evaluation: #{e.message}", {
|
|
148
|
+
error: e.class.name,
|
|
149
|
+
details: {
|
|
150
|
+
condition: conditions,
|
|
151
|
+
context: context
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
return false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if conditions.is_a?(Hash) && conditions[:and] && conditions[:and].is_a?(Array)
|
|
159
|
+
return conditions[:and].all? { |c| all_conditions_are_matched(c, context) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if conditions.is_a?(Hash) && conditions["and"] && conditions["and"].is_a?(Array)
|
|
163
|
+
return conditions["and"].all? { |c| all_conditions_are_matched(c, context) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if conditions.is_a?(Hash) && conditions[:or] && conditions[:or].is_a?(Array)
|
|
167
|
+
return conditions[:or].any? { |c| all_conditions_are_matched(c, context) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
if conditions.is_a?(Hash) && conditions["or"] && conditions["or"].is_a?(Array)
|
|
171
|
+
return conditions["or"].any? { |c| all_conditions_are_matched(c, context) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
if conditions.is_a?(Hash) && conditions[:not] && conditions[:not].is_a?(Array)
|
|
175
|
+
return conditions[:not].all? do
|
|
176
|
+
all_conditions_are_matched({ and: conditions[:not] }, context) == false
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
if conditions.is_a?(Hash) && conditions["not"] && conditions["not"].is_a?(Array)
|
|
181
|
+
return conditions["not"].all? do
|
|
182
|
+
all_conditions_are_matched({ "and" => conditions["not"] }, context) == false
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if conditions.is_a?(Array)
|
|
187
|
+
return conditions.all? { |c| all_conditions_are_matched(c, context) }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
false
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Check if a segment is matched against context
|
|
194
|
+
# @param segment [Hash] Segment to evaluate
|
|
195
|
+
# @param context [Hash] Context to evaluate against
|
|
196
|
+
# @return [Boolean] True if segment matches
|
|
197
|
+
def segment_is_matched(segment, context)
|
|
198
|
+
all_conditions_are_matched(segment[:conditions], context)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Check if all segments are matched against context
|
|
202
|
+
# @param group_segments [String, Array, Hash] Segments to evaluate
|
|
203
|
+
# @param context [Hash] Context to evaluate against
|
|
204
|
+
# @return [Boolean] True if all segments match
|
|
205
|
+
def all_segments_are_matched(group_segments, context)
|
|
206
|
+
if group_segments == "*"
|
|
207
|
+
return true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if group_segments.is_a?(String)
|
|
211
|
+
segment = get_segment(group_segments)
|
|
212
|
+
|
|
213
|
+
if segment
|
|
214
|
+
return segment_is_matched(segment, context)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
return false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if group_segments.is_a?(Hash)
|
|
221
|
+
if group_segments[:and] && group_segments[:and].is_a?(Array)
|
|
222
|
+
return group_segments[:and].all? { |group_segment| all_segments_are_matched(group_segment, context) }
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if group_segments["and"] && group_segments["and"].is_a?(Array)
|
|
226
|
+
return group_segments["and"].all? { |group_segment| all_segments_are_matched(group_segment, context) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if group_segments[:or] && group_segments[:or].is_a?(Array)
|
|
230
|
+
return group_segments[:or].any? { |group_segment| all_segments_are_matched(group_segment, context) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if group_segments["or"] && group_segments["or"].is_a?(Array)
|
|
234
|
+
return group_segments["or"].any? { |group_segment| all_segments_are_matched(group_segment, context) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if group_segments[:not] && group_segments[:not].is_a?(Array)
|
|
238
|
+
return group_segments[:not].all? do
|
|
239
|
+
all_segments_are_matched({ and: group_segments[:not] }, context) == false
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if group_segments["not"] && group_segments["not"].is_a?(Array)
|
|
244
|
+
return group_segments["not"].all? do
|
|
245
|
+
all_segments_are_matched({ "and" => group_segments["not"] }, context) == false
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
if group_segments.is_a?(Array)
|
|
251
|
+
return group_segments.all? { |group_segment| all_segments_are_matched(group_segment, context) }
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Get matched traffic based on context
|
|
258
|
+
# @param traffic [Array<Hash>] Traffic array to search
|
|
259
|
+
# @param context [Hash] Context to evaluate against
|
|
260
|
+
# @return [Hash, nil] Matched traffic or nil
|
|
261
|
+
def get_matched_traffic(traffic, context)
|
|
262
|
+
traffic.find do |t|
|
|
263
|
+
segments = parse_segments_if_stringified(t[:segments])
|
|
264
|
+
matched = all_segments_are_matched(segments, context)
|
|
265
|
+
next false unless matched
|
|
266
|
+
true
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Get matched allocation based on bucket value
|
|
271
|
+
# @param traffic [Hash] Traffic object
|
|
272
|
+
# @param bucket_value [Numeric] Bucket value to match
|
|
273
|
+
# @return [Hash, nil] Matched allocation or nil
|
|
274
|
+
def get_matched_allocation(traffic, bucket_value)
|
|
275
|
+
return nil unless traffic[:allocation]
|
|
276
|
+
|
|
277
|
+
traffic[:allocation].find do |allocation|
|
|
278
|
+
start, end_val = allocation[:range]
|
|
279
|
+
start <= bucket_value && end_val >= bucket_value
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Get matched force based on context
|
|
284
|
+
# @param feature_key [String, Hash] Feature key or feature object
|
|
285
|
+
# @param context [Hash] Context to evaluate against
|
|
286
|
+
# @return [Hash] Force result with force and forceIndex
|
|
287
|
+
def get_matched_force(feature_key, context)
|
|
288
|
+
result = {
|
|
289
|
+
force: nil,
|
|
290
|
+
forceIndex: nil
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
feature = feature_key.is_a?(String) ? get_feature(feature_key) : feature_key
|
|
294
|
+
|
|
295
|
+
return result unless feature && feature[:force]
|
|
296
|
+
|
|
297
|
+
feature[:force].each_with_index do |current_force, i|
|
|
298
|
+
if current_force[:conditions] && all_conditions_are_matched(
|
|
299
|
+
parse_conditions_if_stringified(current_force[:conditions]), context
|
|
300
|
+
)
|
|
301
|
+
result[:force] = current_force
|
|
302
|
+
result[:forceIndex] = i
|
|
303
|
+
break
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
if current_force[:segments] && all_segments_are_matched(
|
|
307
|
+
parse_segments_if_stringified(current_force[:segments]), context
|
|
308
|
+
)
|
|
309
|
+
result[:force] = current_force
|
|
310
|
+
result[:forceIndex] = i
|
|
311
|
+
break
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
result
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Parse conditions if they are stringified
|
|
319
|
+
# @param conditions [String, Array, Hash] Conditions to parse
|
|
320
|
+
# @return [Array, Hash, String] Parsed conditions
|
|
321
|
+
def parse_conditions_if_stringified(conditions)
|
|
322
|
+
return conditions unless conditions.is_a?(String)
|
|
323
|
+
|
|
324
|
+
return conditions if conditions == "*"
|
|
325
|
+
|
|
326
|
+
begin
|
|
327
|
+
JSON.parse(conditions)
|
|
328
|
+
rescue => e
|
|
329
|
+
@logger.error("Error parsing conditions", {
|
|
330
|
+
error: e,
|
|
331
|
+
details: {
|
|
332
|
+
conditions: conditions
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
conditions
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Parse segments if they are stringified
|
|
340
|
+
# @param segments [String, Array, Hash] Segments to parse
|
|
341
|
+
# @return [Array, Hash, String] Parsed segments
|
|
342
|
+
def parse_segments_if_stringified(segments)
|
|
343
|
+
if segments.is_a?(String) && (segments.start_with?("{") || segments.start_with?("["))
|
|
344
|
+
return JSON.parse(segments)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
segments
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Featurevisor
|
|
4
|
+
# Event names for the emitter
|
|
5
|
+
EVENT_NAMES = %w[datafile_set context_set sticky_set].freeze
|
|
6
|
+
|
|
7
|
+
# Event emitter class for handling event subscriptions and triggers
|
|
8
|
+
class Emitter
|
|
9
|
+
attr_reader :listeners
|
|
10
|
+
|
|
11
|
+
# Initialize a new emitter
|
|
12
|
+
def initialize
|
|
13
|
+
@listeners = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Subscribe to an event
|
|
17
|
+
# @param event_name [String] Name of the event to listen to
|
|
18
|
+
# @param callback [Proc] Callback function to execute when event is triggered
|
|
19
|
+
# @return [Proc] Unsubscribe function
|
|
20
|
+
def on(event_name, callback)
|
|
21
|
+
@listeners[event_name] ||= []
|
|
22
|
+
listeners = @listeners[event_name]
|
|
23
|
+
listeners << callback
|
|
24
|
+
|
|
25
|
+
is_active = true
|
|
26
|
+
|
|
27
|
+
# Return unsubscribe function
|
|
28
|
+
-> do
|
|
29
|
+
return unless is_active
|
|
30
|
+
|
|
31
|
+
is_active = false
|
|
32
|
+
index = listeners.index(callback)
|
|
33
|
+
listeners.delete_at(index) if index && index >= 0
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Trigger an event with optional details
|
|
38
|
+
# @param event_name [String] Name of the event to trigger
|
|
39
|
+
# @param details [Hash] Optional details to pass to event handlers
|
|
40
|
+
def trigger(event_name, details = {})
|
|
41
|
+
listeners = @listeners[event_name]
|
|
42
|
+
|
|
43
|
+
return unless listeners
|
|
44
|
+
|
|
45
|
+
listeners.each do |listener|
|
|
46
|
+
begin
|
|
47
|
+
listener.call(details)
|
|
48
|
+
rescue => err
|
|
49
|
+
# Log error but don't stop execution
|
|
50
|
+
warn "Error in event listener: #{err.message}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Clear all event listeners
|
|
56
|
+
def clear_all
|
|
57
|
+
@listeners = {}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|