featurevisor 0.3.0 → 1.0.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.
@@ -27,29 +27,32 @@ module FeaturevisorCLI
27
27
  exit 1
28
28
  end
29
29
 
30
- puts ""
31
- puts "Running benchmark for feature \"#{@options.feature}\"..."
32
- puts ""
33
-
34
- # Parse context if provided
35
30
  context = parse_context
31
+ targets = resolve_targets
32
+ (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) }
33
+ end
36
34
 
37
- puts "Building datafile containing all features for \"#{@options.environment}\"..."
38
- datafile_build_start = Time.now
35
+ private
39
36
 
40
- # Build datafile by executing the featurevisor build command
41
- datafile = build_datafile(@options.environment)
37
+ def run_for_target(context, target)
38
+ datafile_build_start = Time.now
39
+ datafile = build_datafile(@options.environment, target)
42
40
  datafile_build_duration = Time.now - datafile_build_start
43
41
  datafile_build_duration_ms = (datafile_build_duration * 1000).round
44
42
 
45
- puts "Datafile build duration: #{datafile_build_duration_ms}ms"
43
+ puts "\nBenchmark Featurevisor feature"
44
+ puts " Feature: #{@options.feature}"
45
+ puts " Environment: #{@options.environment}"
46
+ puts " Target: #{target}" if target
47
+ puts " Iterations: #{@options.n}"
48
+ puts " Build duration: #{datafile_build_duration_ms}ms"
46
49
 
47
50
  # Calculate datafile size
48
51
  datafile_size = datafile.to_json.bytesize
49
52
  puts "Datafile size: #{(datafile_size / 1024.0).round(2)} kB"
50
53
 
51
54
  # Create SDK instance with the datafile
52
- instance = create_instance(datafile)
55
+ instance = create_featurevisor(datafile)
53
56
  puts "...SDK initialized"
54
57
 
55
58
  puts ""
@@ -73,10 +76,26 @@ module FeaturevisorCLI
73
76
  value_output = format_value(output[:value])
74
77
  puts "Evaluated value : #{value_output}"
75
78
  puts "Total duration : #{pretty_duration(output[:duration])}"
76
- puts "Average duration: #{pretty_duration(output[:duration] / @options.n)}"
79
+ puts "Minimum duration: #{format_duration_ms(output[:min_duration])}"
80
+ puts "Average duration: #{format_duration_ms(output[:average_duration])}"
81
+ puts "Maximum duration: #{format_duration_ms(output[:max_duration])}"
77
82
  end
78
83
 
79
- private
84
+ def resolve_targets
85
+ return [] if @options.targets.empty?
86
+ stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path)
87
+ unless status.success?
88
+ puts stderr
89
+ exit 1
90
+ end
91
+ available = JSON.parse(stdout).map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target }
92
+ unknown = @options.targets.find { |target| !available.include?(target) }
93
+ if unknown
94
+ puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}."
95
+ exit 1
96
+ end
97
+ @options.targets
98
+ end
80
99
 
81
100
  def parse_context
82
101
  if @options.context
@@ -93,24 +112,15 @@ module FeaturevisorCLI
93
112
  end
94
113
  end
95
114
 
96
- def build_datafile(environment)
115
+ def build_datafile(environment, target = nil)
97
116
  puts "Building datafile for environment: #{environment}..."
98
117
 
99
- # Build the command similar to Go implementation
100
118
  command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
101
-
102
- # Add schema version if specified
103
- if @options.schema_version && !@options.schema_version.empty?
104
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--json"]
105
- end
119
+ command_parts << "--target=#{target}" if target
106
120
 
107
121
  # Add inflate if specified
108
122
  if @options.inflate && @options.inflate > 0
109
- if @options.schema_version && !@options.schema_version.empty?
110
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--inflate=#{@options.inflate}", "--json"]
111
- else
112
- command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{@options.inflate}", "--json"]
113
- end
123
+ command_parts << "--inflate=#{@options.inflate}"
114
124
  end
115
125
 
116
126
  command = command_parts.join(" ")
@@ -142,9 +152,9 @@ module FeaturevisorCLI
142
152
  stdout
143
153
  end
144
154
 
145
- def create_instance(datafile)
155
+ def create_featurevisor(datafile)
146
156
  # Create a real Featurevisor instance
147
- instance = Featurevisor.create_instance(
157
+ instance = Featurevisor.create_featurevisor(
148
158
  log_level: get_logger_level
149
159
  )
150
160
 
@@ -164,61 +174,53 @@ module FeaturevisorCLI
164
174
  end
165
175
  end
166
176
 
167
- def benchmark_feature_flag(instance, feature_key, context, n)
168
- start_time = Time.now
169
-
170
- # Get the actual feature value from the SDK
171
- value = instance.is_enabled(feature_key, context)
177
+ def benchmark_evaluation(n)
178
+ value = nil
179
+ total_duration_ns = 0
180
+ min_duration_ns = nil
181
+ max_duration_ns = 0
172
182
 
173
- # Benchmark the evaluation
174
183
  n.times do
175
- instance.is_enabled(feature_key, context)
184
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
185
+ value = yield
186
+ duration_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time
187
+
188
+ total_duration_ns += duration_ns
189
+ min_duration_ns = duration_ns if min_duration_ns.nil? || duration_ns < min_duration_ns
190
+ max_duration_ns = duration_ns if duration_ns > max_duration_ns
176
191
  end
177
192
 
178
- duration = Time.now - start_time
193
+ duration = total_duration_ns / 1_000_000_000.0
179
194
 
180
195
  {
181
196
  value: value,
182
- duration: duration
197
+ duration: duration,
198
+ min_duration: (min_duration_ns || 0) / 1_000_000_000.0,
199
+ average_duration: duration / n,
200
+ max_duration: max_duration_ns / 1_000_000_000.0
183
201
  }
184
202
  end
185
203
 
186
- def benchmark_feature_variation(instance, feature_key, context, n)
187
- start_time = Time.now
204
+ def format_duration_ms(duration)
205
+ format("%.6fms", duration * 1000)
206
+ end
188
207
 
189
- # Get the actual feature variation from the SDK
190
- value = instance.get_variation(feature_key, context)
208
+ def benchmark_feature_flag(instance, feature_key, context, n)
209
+ benchmark_evaluation(n) do
210
+ instance.is_enabled(feature_key, context)
211
+ end
212
+ end
191
213
 
192
- # Benchmark the evaluation
193
- n.times do
214
+ def benchmark_feature_variation(instance, feature_key, context, n)
215
+ benchmark_evaluation(n) do
194
216
  instance.get_variation(feature_key, context)
195
217
  end
196
-
197
- duration = Time.now - start_time
198
-
199
- {
200
- value: value,
201
- duration: duration
202
- }
203
218
  end
204
219
 
205
220
  def benchmark_feature_variable(instance, feature_key, variable_key, context, n)
206
- start_time = Time.now
207
-
208
- # Get the actual variable value from the SDK
209
- value = instance.get_variable(feature_key, variable_key, context)
210
-
211
- # Benchmark the evaluation
212
- n.times do
221
+ benchmark_evaluation(n) do
213
222
  instance.get_variable(feature_key, variable_key, context)
214
223
  end
215
-
216
- duration = Time.now - start_time
217
-
218
- {
219
- value: value,
220
- duration: duration
221
- }
222
224
  end
223
225
 
224
226
  def format_value(value)
data/bin/commands/test.rb CHANGED
@@ -21,16 +21,17 @@ module FeaturevisorCLI
21
21
  # Get project configuration
22
22
  config = get_config
23
23
  environments = get_environments(config)
24
- segments_by_key = get_segments
25
-
26
- # Use CLI schemaVersion option or fallback to config
27
- schema_version = @options.schema_version
28
- if schema_version.nil? || schema_version.empty?
29
- schema_version = config[:schemaVersion]
24
+ available_targets = get_targets
25
+ unknown_target = @options.targets.find { |target| !available_targets.include?(target) }
26
+ if unknown_target
27
+ puts "Unknown target \"#{unknown_target}\". Available targets: #{available_targets.empty? ? "none" : available_targets.join(", ")}."
28
+ exit 1
30
29
  end
30
+ targets = @options.targets.empty? ? available_targets : @options.targets
31
+ segments_by_key = get_segments
31
32
 
32
- # Build datafiles for all environments (+ scoped/tagged variants)
33
- datafiles_by_key = build_datafiles(config, environments, schema_version, @options.inflate)
33
+ # Build base and Target datafiles for all environments.
34
+ datafiles_by_key = build_datafiles(environments, targets, @options.inflate)
34
35
 
35
36
  puts ""
36
37
 
@@ -84,6 +85,34 @@ module FeaturevisorCLI
84
85
  end
85
86
  end
86
87
 
88
+ def get_targets
89
+ puts "Getting targets..."
90
+ command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --targets --json)"
91
+ targets_output = execute_command(command)
92
+
93
+ begin
94
+ targets = JSON.parse(targets_output, symbolize_names: true)
95
+
96
+ if targets.is_a?(Array)
97
+ targets.filter_map do |target|
98
+ if target.is_a?(Hash)
99
+ target[:name] || target[:key]
100
+ else
101
+ target
102
+ end
103
+ end
104
+ elsif targets.is_a?(Hash)
105
+ targets.keys
106
+ else
107
+ []
108
+ end
109
+ rescue JSON::ParserError => e
110
+ puts "Error: Failed to parse targets JSON: #{e.message}"
111
+ puts "Command output: #{targets_output}"
112
+ exit 1
113
+ end
114
+ end
115
+
87
116
  def get_environments(config)
88
117
  environments = config[:environments]
89
118
 
@@ -97,23 +126,15 @@ module FeaturevisorCLI
97
126
  environment == false ? false : environment
98
127
  end
99
128
 
100
- def scoped_datafile_key(environment, scope_name)
101
- if environment == false
102
- "scope-#{scope_name}"
103
- else
104
- "#{environment}-scope-#{scope_name}"
105
- end
106
- end
107
-
108
- def tagged_datafile_key(environment, tag)
129
+ def target_datafile_key(environment, target)
109
130
  if environment == false
110
- "tag-#{tag}"
131
+ "false-target-#{target}"
111
132
  else
112
- "#{environment}-tag-#{tag}"
133
+ "#{environment}-target-#{target}"
113
134
  end
114
135
  end
115
136
 
116
- def build_datafiles(config, environments, schema_version, inflate)
137
+ def build_datafiles(environments, targets, inflate)
117
138
  datafiles_by_key = {}
118
139
 
119
140
  environments.each do |environment|
@@ -122,61 +143,35 @@ module FeaturevisorCLI
122
143
 
123
144
  datafiles_by_key[base_datafile_key(environment)] = build_single_datafile(
124
145
  environment: environment,
125
- schema_version: schema_version,
126
146
  inflate: inflate
127
147
  )
128
148
 
129
- if @options.with_scopes && config[:scopes].is_a?(Array)
130
- config[:scopes].each do |scope|
131
- next unless scope[:name]
132
-
133
- puts "Building scoped datafile for scope: #{scope[:name]}..."
134
- datafiles_by_key[scoped_datafile_key(environment, scope[:name])] = build_single_datafile(
135
- environment: environment,
136
- schema_version: schema_version,
137
- inflate: inflate,
138
- scope: scope[:name]
139
- )
140
- end
141
- end
142
-
143
- if @options.with_tags && config[:tags].is_a?(Array)
144
- config[:tags].each do |tag|
145
- puts "Building tagged datafile for tag: #{tag}..."
146
- datafiles_by_key[tagged_datafile_key(environment, tag)] = build_single_datafile(
147
- environment: environment,
148
- schema_version: schema_version,
149
- inflate: inflate,
150
- tag: tag
151
- )
152
- end
149
+ targets.each do |target|
150
+ puts "Building datafile for target: #{target}..."
151
+ datafiles_by_key[target_datafile_key(environment, target)] = build_single_datafile(
152
+ environment: environment,
153
+ inflate: inflate,
154
+ target: target
155
+ )
153
156
  end
154
157
  end
155
158
 
156
159
  datafiles_by_key
157
160
  end
158
161
 
159
- def build_single_datafile(environment:, schema_version:, inflate:, scope: nil, tag: nil)
162
+ def build_single_datafile(environment:, inflate:, target: nil)
160
163
  command_parts = ["npx", "featurevisor", "build", "--json"]
161
164
 
162
165
  if environment != false && !environment.nil?
163
166
  command_parts << "--environment=#{Shellwords.escape(environment.to_s)}"
164
167
  end
165
168
 
166
- if schema_version && !schema_version.empty?
167
- command_parts << "--schemaVersion=#{Shellwords.escape(schema_version.to_s)}"
168
- end
169
-
170
169
  if inflate && inflate > 0
171
170
  command_parts << "--inflate=#{inflate}"
172
171
  end
173
172
 
174
- if scope
175
- command_parts << "--scope=#{Shellwords.escape(scope.to_s)}"
176
- end
177
-
178
- if tag
179
- command_parts << "--tag=#{Shellwords.escape(tag.to_s)}"
173
+ if target
174
+ command_parts << "--target=#{Shellwords.escape(target.to_s)}"
180
175
  end
181
176
 
182
177
  command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})"
@@ -224,29 +219,15 @@ module FeaturevisorCLI
224
219
  end
225
220
  end
226
221
 
227
- def get_scope_context(config, scope_name)
228
- return {} unless scope_name && config[:scopes].is_a?(Array)
229
-
230
- scope = config[:scopes].find { |s| s[:name] == scope_name }
231
- return {} unless scope && scope[:context].is_a?(Hash)
232
-
233
- parse_context(scope[:context])
234
- end
235
-
236
222
  def resolve_datafile_for_assertion(assertion, datafiles_by_key)
237
223
  environment = assertion.key?(:environment) ? assertion[:environment] : false
238
224
  environment = false if environment.nil?
239
225
 
240
- scoped_key = assertion[:scope] ? scoped_datafile_key(environment, assertion[:scope]) : nil
241
- tagged_key = assertion[:tag] ? tagged_datafile_key(environment, assertion[:tag]) : nil
226
+ target_key = assertion[:target] ? target_datafile_key(environment, assertion[:target]) : nil
242
227
  base_key = base_datafile_key(environment)
243
228
 
244
- if scoped_key && datafiles_by_key.key?(scoped_key)
245
- return datafiles_by_key[scoped_key]
246
- end
247
-
248
- if tagged_key && datafiles_by_key.key?(tagged_key)
249
- return datafiles_by_key[tagged_key]
229
+ if target_key && datafiles_by_key.key?(target_key)
230
+ return datafiles_by_key[target_key]
250
231
  end
251
232
 
252
233
  datafiles_by_key[base_key]
@@ -255,19 +236,19 @@ module FeaturevisorCLI
255
236
  def create_tester_instance(datafile, level, assertion)
256
237
  sticky = parse_sticky(assertion[:sticky])
257
238
 
258
- Featurevisor.create_instance(
239
+ Featurevisor.create_featurevisor(
259
240
  datafile: datafile,
260
241
  sticky: sticky,
261
242
  log_level: level,
262
- hooks: [
243
+ modules: [
263
244
  {
264
- name: "tester-hook",
245
+ name: "test-module",
265
246
  bucket_value: ->(options) do
266
247
  at = assertion[:at]
267
248
  if at.is_a?(Numeric)
268
249
  (at * 1000).to_i
269
250
  else
270
- options.bucket_value
251
+ options[:bucket_value]
271
252
  end
272
253
  end
273
254
  }
@@ -284,6 +265,12 @@ module FeaturevisorCLI
284
265
  tests.each do |test|
285
266
  test_key = test[:key]
286
267
  assertions = test[:assertions] || []
268
+ if test[:feature] && !@options.targets.empty?
269
+ assertions = assertions.select do |assertion|
270
+ assertion[:target].nil? || @options.targets.include?(assertion[:target])
271
+ end
272
+ next if assertions.empty?
273
+ end
287
274
  results = ""
288
275
  test_has_error = false
289
276
  test_duration = 0.0
@@ -297,19 +284,13 @@ module FeaturevisorCLI
297
284
  if datafile.nil?
298
285
  test_result = {
299
286
  has_error: true,
300
- errors: " ✘ no datafile found for assertion scope/tag/environment combination\n",
287
+ errors: " ✘ no datafile found for assertion target/environment combination\n",
301
288
  duration: 0
302
289
  }
303
290
  end
304
291
 
305
292
  if datafile
306
293
  instance = create_tester_instance(datafile, level, assertion)
307
- scope_context = {}
308
-
309
- if assertion[:scope] && !@options.with_scopes
310
- # If not using scoped datafiles, mimic JS behavior by merging scope context.
311
- scope_context = get_scope_context(config, assertion[:scope])
312
- end
313
294
 
314
295
  # Show datafile if requested
315
296
  if @options.show_datafile
@@ -318,7 +299,7 @@ module FeaturevisorCLI
318
299
  puts ""
319
300
  end
320
301
 
321
- test_result = run_test_feature(assertion, test[:feature], instance, level, scope_context)
302
+ test_result = run_test_feature(assertion, test[:feature], instance, level)
322
303
  end
323
304
  elsif test[:segment]
324
305
  segment_key = test[:segment]
@@ -366,9 +347,8 @@ module FeaturevisorCLI
366
347
  end
367
348
  end
368
349
 
369
- def run_test_feature(assertion, feature_key, instance, level, scope_context = {})
350
+ def run_test_feature(assertion, feature_key, instance, level)
370
351
  context = parse_context(assertion[:context])
371
- context = { **scope_context, **context } if scope_context && !scope_context.empty?
372
352
  sticky = parse_sticky(assertion[:sticky])
373
353
 
374
354
  # Set context and sticky for this assertion
@@ -680,7 +660,7 @@ module FeaturevisorCLI
680
660
  }
681
661
 
682
662
  # Create SDK instance for segment testing
683
- instance = Featurevisor.create_instance(
663
+ instance = Featurevisor.create_featurevisor(
684
664
  datafile: datafile,
685
665
  log_level: level
686
666
  )
@@ -768,16 +748,14 @@ module FeaturevisorCLI
768
748
  return instance.send(method_name, feature_key, variable_key, context, override_options)
769
749
  end
770
750
 
771
- if instance.respond_to?(:parent) && instance.respond_to?(:sticky)
772
- parent = instance.parent
773
- combined_context = if instance.respond_to?(:context)
774
- { **(instance.context || {}), **context }
775
- else
776
- context
777
- end
751
+ if instance.is_a?(Featurevisor::ChildInstance)
752
+ parent = instance.instance_variable_get(:@parent)
753
+ child_context = instance.instance_variable_get(:@context) || {}
754
+ child_sticky = instance.instance_variable_get(:@sticky)
755
+ combined_context = { **child_context, **context }
778
756
 
779
757
  combined_options = {
780
- sticky: instance.sticky,
758
+ __featurevisor_child_sticky: child_sticky,
781
759
  **override_options
782
760
  }
783
761
 
@@ -3,8 +3,6 @@
3
3
  module Featurevisor
4
4
  # Child instance class for managing child contexts and sticky features
5
5
  class ChildInstance
6
- attr_reader :parent, :context, :sticky, :emitter
7
-
8
6
  # Initialize a new child instance
9
7
  # @param options [Hash] Child instance options
10
8
  # @option options [Instance] :parent Parent instance
@@ -94,8 +92,8 @@ module Featurevisor
94
92
  **context
95
93
  },
96
94
  {
97
- sticky: @sticky,
98
- **options
95
+ **options,
96
+ __featurevisor_child_sticky: @sticky
99
97
  }
100
98
  )
101
99
  end
@@ -113,8 +111,8 @@ module Featurevisor
113
111
  **context
114
112
  },
115
113
  {
116
- sticky: @sticky,
117
- **options
114
+ **options,
115
+ __featurevisor_child_sticky: @sticky
118
116
  }
119
117
  )
120
118
  end
@@ -134,8 +132,8 @@ module Featurevisor
134
132
  **context
135
133
  },
136
134
  {
137
- sticky: @sticky,
138
- **options
135
+ **options,
136
+ __featurevisor_child_sticky: @sticky
139
137
  }
140
138
  )
141
139
  end
@@ -155,8 +153,8 @@ module Featurevisor
155
153
  **context
156
154
  },
157
155
  {
158
- sticky: @sticky,
159
- **options
156
+ **options,
157
+ __featurevisor_child_sticky: @sticky
160
158
  }
161
159
  )
162
160
  end
@@ -176,8 +174,8 @@ module Featurevisor
176
174
  **context
177
175
  },
178
176
  {
179
- sticky: @sticky,
180
- **options
177
+ **options,
178
+ __featurevisor_child_sticky: @sticky
181
179
  }
182
180
  )
183
181
  end
@@ -197,8 +195,8 @@ module Featurevisor
197
195
  **context
198
196
  },
199
197
  {
200
- sticky: @sticky,
201
- **options
198
+ **options,
199
+ __featurevisor_child_sticky: @sticky
202
200
  }
203
201
  )
204
202
  end
@@ -218,8 +216,8 @@ module Featurevisor
218
216
  **context
219
217
  },
220
218
  {
221
- sticky: @sticky,
222
- **options
219
+ **options,
220
+ __featurevisor_child_sticky: @sticky
223
221
  }
224
222
  )
225
223
  end
@@ -239,8 +237,8 @@ module Featurevisor
239
237
  **context
240
238
  },
241
239
  {
242
- sticky: @sticky,
243
- **options
240
+ **options,
241
+ __featurevisor_child_sticky: @sticky
244
242
  }
245
243
  )
246
244
  end
@@ -260,8 +258,8 @@ module Featurevisor
260
258
  **context
261
259
  },
262
260
  {
263
- sticky: @sticky,
264
- **options
261
+ **options,
262
+ __featurevisor_child_sticky: @sticky
265
263
  }
266
264
  )
267
265
  end
@@ -281,8 +279,8 @@ module Featurevisor
281
279
  **context
282
280
  },
283
281
  {
284
- sticky: @sticky,
285
- **options
282
+ **options,
283
+ __featurevisor_child_sticky: @sticky
286
284
  }
287
285
  )
288
286
  end
@@ -300,8 +298,8 @@ module Featurevisor
300
298
  },
301
299
  feature_keys,
302
300
  {
303
- sticky: @sticky,
304
- **options
301
+ **options,
302
+ __featurevisor_child_sticky: @sticky
305
303
  }
306
304
  )
307
305
  end