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.
data/bin/cli.rb ADDED
@@ -0,0 +1,142 @@
1
+ require "optparse"
2
+ require "json"
3
+ require_relative "commands"
4
+
5
+ module FeaturevisorCLI
6
+ class Options
7
+ attr_accessor :command, :assertion_pattern, :context, :environment, :feature,
8
+ :key_pattern, :n, :only_failures, :quiet, :variable, :variation,
9
+ :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path,
10
+ :populate_uuid
11
+
12
+ def initialize
13
+ @n = 1000
14
+ @project_directory_path = Dir.pwd
15
+ @populate_uuid = []
16
+ end
17
+ end
18
+
19
+ class Parser
20
+ def self.parse(args)
21
+ options = Options.new
22
+
23
+ if args.empty?
24
+ return options
25
+ end
26
+
27
+ options.command = args[0]
28
+ remaining_args = args[1..-1]
29
+
30
+ OptionParser.new do |opts|
31
+ opts.banner = "Usage: featurevisor [command] [options]"
32
+
33
+ opts.on("--assertionPattern=PATTERN", "Assertion pattern") do |v|
34
+ options.assertion_pattern = v
35
+ end
36
+
37
+ opts.on("--context=CONTEXT", "Context JSON") do |v|
38
+ options.context = v
39
+ end
40
+
41
+ opts.on("--environment=ENV", "Environment (required for benchmark)") do |v|
42
+ options.environment = v
43
+ end
44
+
45
+ opts.on("--feature=FEATURE", "Feature key (required for benchmark)") do |v|
46
+ options.feature = v
47
+ end
48
+
49
+ opts.on("--keyPattern=PATTERN", "Key pattern") do |v|
50
+ options.key_pattern = v
51
+ end
52
+
53
+ opts.on("-n", "--iterations=N", "--n=N", Integer, "Number of iterations (default: 1000)") do |v|
54
+ options.n = v
55
+ end
56
+
57
+ opts.on("--onlyFailures", "Only show failures") do
58
+ options.only_failures = true
59
+ end
60
+
61
+ opts.on("--quiet", "Quiet mode") do
62
+ options.quiet = true
63
+ end
64
+
65
+ opts.on("--variable=VARIABLE", "Variable key") do |v|
66
+ options.variable = v
67
+ end
68
+
69
+ opts.on("--variation", "Variation mode") do
70
+ options.variation = true
71
+ end
72
+
73
+ opts.on("--verbose", "Verbose mode") do
74
+ options.verbose = true
75
+ end
76
+
77
+ opts.on("--inflate=N", Integer, "Inflate mode") do |v|
78
+ options.inflate = v
79
+ end
80
+
81
+ opts.on("--showDatafile", "Show datafile content for each test") do
82
+ options.show_datafile = true
83
+ end
84
+
85
+ opts.on("--schemaVersion=VERSION", "Schema version") do |v|
86
+ options.schema_version = v
87
+ end
88
+
89
+ opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v|
90
+ options.project_directory_path = v
91
+ end
92
+
93
+ opts.on("--populateUuid=KEY", "Populate UUID for attribute key") do |v|
94
+ options.populate_uuid << v
95
+ end
96
+
97
+ opts.on("-h", "--help", "Show this help message") do
98
+ puts opts
99
+ exit
100
+ end
101
+ end.parse!(remaining_args)
102
+
103
+ options
104
+ end
105
+ end
106
+
107
+ def self.run(args)
108
+ options = Parser.parse(args)
109
+
110
+ case options.command
111
+ when "test"
112
+ Commands::Test.run(options)
113
+ when "benchmark"
114
+ Commands::Benchmark.run(options)
115
+ when "assess-distribution"
116
+ Commands::AssessDistribution.run(options)
117
+ else
118
+ show_help
119
+ end
120
+ end
121
+
122
+ def self.show_help
123
+ puts "Featurevisor Ruby SDK CLI"
124
+ puts ""
125
+ puts "Usage: featurevisor [command] [options]"
126
+ puts ""
127
+ puts "Commands:"
128
+ puts " test Run tests for features and segments"
129
+ puts " benchmark Benchmark feature evaluation performance"
130
+ puts " assess-distribution Assess feature distribution across contexts"
131
+ puts ""
132
+ puts "Learn more at https://featurevisor.com/docs/sdks/ruby/"
133
+ puts ""
134
+ puts "Examples:"
135
+ puts " featurevisor test"
136
+ puts " featurevisor test --keyPattern=pattern"
137
+ puts " featurevisor benchmark --feature=myFeature --environment=dev --n=10000"
138
+ puts " featurevisor assess-distribution --feature=myFeature --n=10000"
139
+ puts ""
140
+ puts "Note: benchmark command requires --environment and --feature options"
141
+ end
142
+ end
@@ -0,0 +1,236 @@
1
+ require "json"
2
+ require "securerandom"
3
+ require "open3"
4
+
5
+ module FeaturevisorCLI
6
+ module Commands
7
+ class AssessDistribution
8
+ # UUID_LENGTHS matches the TypeScript implementation
9
+ UUID_LENGTHS = [4, 2, 2, 2, 6]
10
+
11
+ def self.run(options)
12
+ new(options).run
13
+ end
14
+
15
+ def initialize(options)
16
+ @options = options
17
+ @project_path = options.project_directory_path
18
+ end
19
+
20
+ def run
21
+ # Validate required options
22
+ unless @options.environment
23
+ puts "Error: --environment is required for assess-distribution command"
24
+ exit 1
25
+ end
26
+
27
+ unless @options.feature
28
+ puts "Error: --feature is required for assess-distribution command"
29
+ exit 1
30
+ end
31
+
32
+ puts ""
33
+ puts "Assessing distribution for feature: \"#{@options.feature}\"..."
34
+ puts ""
35
+
36
+ # Parse context if provided
37
+ context = parse_context
38
+
39
+ # Print context information
40
+ if @options.context
41
+ puts "Against context: #{@options.context}"
42
+ else
43
+ puts "Against context: {}"
44
+ end
45
+
46
+ puts "Running #{@options.n} times..."
47
+ puts ""
48
+
49
+ # Build datafile
50
+ datafile = build_datafile(@options.environment)
51
+
52
+ # Create SDK instance
53
+ instance = create_instance(datafile)
54
+
55
+ # Check if feature has variations
56
+ feature = instance.get_feature(@options.feature)
57
+ has_variations = feature && feature[:variations] && feature[:variations].length > 0
58
+
59
+ # Initialize evaluation counters
60
+ flag_evaluations = {
61
+ "enabled" => 0,
62
+ "disabled" => 0
63
+ }
64
+ variation_evaluations = {}
65
+
66
+ # Run evaluations
67
+ @options.n.times do |i|
68
+ # Create a copy of context for this iteration
69
+ context_copy = context.dup
70
+
71
+ # Populate UUIDs if requested
72
+ if @options.populate_uuid.any?
73
+ @options.populate_uuid.each do |key|
74
+ context_copy[key.to_sym] = generate_uuid
75
+ end
76
+ end
77
+
78
+ # Evaluate flag
79
+ flag_evaluation = instance.is_enabled(@options.feature, context_copy)
80
+ if flag_evaluation
81
+ flag_evaluations["enabled"] += 1
82
+ else
83
+ flag_evaluations["disabled"] += 1
84
+ end
85
+
86
+ # Evaluate variation if feature has variations
87
+ if has_variations
88
+ variation_evaluation = instance.get_variation(@options.feature, context_copy)
89
+ if variation_evaluation
90
+ variation_value = variation_evaluation
91
+ variation_evaluations[variation_value] ||= 0
92
+ variation_evaluations[variation_value] += 1
93
+ end
94
+ end
95
+ end
96
+
97
+ # Print results
98
+ puts "\nFlag evaluations:"
99
+ print_counts(flag_evaluations, @options.n, true)
100
+
101
+ if has_variations
102
+ puts "\nVariation evaluations:"
103
+ print_counts(variation_evaluations, @options.n, true)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def parse_context
110
+ if @options.context
111
+ begin
112
+ context = JSON.parse(@options.context)
113
+ # Convert string keys to symbols for the SDK
114
+ context.transform_keys(&:to_sym)
115
+ rescue JSON::ParserError => e
116
+ puts "Error: Invalid JSON context: #{e.message}"
117
+ exit 1
118
+ end
119
+ else
120
+ {}
121
+ end
122
+ end
123
+
124
+ def build_datafile(environment)
125
+ puts "Building datafile for environment: #{environment}..."
126
+
127
+ # Build the command similar to Go implementation
128
+ command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
129
+
130
+ if @options.schema_version
131
+ command_parts << "--schemaVersion=#{@options.schema_version}"
132
+ end
133
+
134
+ if @options.inflate
135
+ command_parts << "--inflate=#{@options.inflate}"
136
+ end
137
+
138
+ command = command_parts.join(" ")
139
+
140
+ stdout, stderr, exit_status = execute_command(command)
141
+
142
+ if exit_status != 0
143
+ puts "Error: Command failed with exit code #{exit_status}"
144
+ puts "Command: #{command}"
145
+ puts "Stderr: #{stderr}"
146
+ exit 1
147
+ end
148
+
149
+ begin
150
+ JSON.parse(stdout)
151
+ rescue JSON::ParserError => e
152
+ puts "Error: Failed to parse datafile JSON: #{e.message}"
153
+ exit 1
154
+ end
155
+ end
156
+
157
+ def execute_command(command)
158
+ stdout, stderr, exit_status = Open3.capture3(command)
159
+ [stdout, stderr, exit_status.exitstatus]
160
+ end
161
+
162
+ def create_instance(datafile)
163
+ # Convert datafile to proper format for the SDK
164
+ symbolized_datafile = symbolize_keys(datafile)
165
+
166
+ # Create SDK instance
167
+ Featurevisor.create_instance(
168
+ datafile: symbolized_datafile,
169
+ log_level: get_logger_level
170
+ )
171
+ end
172
+
173
+ def symbolize_keys(obj)
174
+ case obj
175
+ when Hash
176
+ obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
177
+ when Array
178
+ obj.map { |item| symbolize_keys(item) }
179
+ else
180
+ obj
181
+ end
182
+ end
183
+
184
+ def get_logger_level
185
+ if @options.verbose
186
+ "debug"
187
+ elsif @options.quiet
188
+ "error"
189
+ else
190
+ "warn"
191
+ end
192
+ end
193
+
194
+ # Generate UUID string matching the TypeScript format
195
+ def generate_uuid
196
+ parts = UUID_LENGTHS.map do |length|
197
+ SecureRandom.hex(length)
198
+ end
199
+ parts.join("-")
200
+ end
201
+
202
+ # Pretty number formatting (simple implementation)
203
+ def pretty_number(n)
204
+ n.to_s
205
+ end
206
+
207
+ # Pretty percentage formatting with 2 decimal places
208
+ def pretty_percentage(count, total)
209
+ if total == 0
210
+ "0.00%"
211
+ else
212
+ percentage = (count.to_f / total * 100).round(2)
213
+ "#{percentage}%"
214
+ end
215
+ end
216
+
217
+ # Print evaluation counts in the same format as TypeScript
218
+ def print_counts(evaluations, n, sort_results = true)
219
+ # Convert to entries for sorting
220
+ entries = evaluations.map { |value, count| { value: value, count: count } }
221
+
222
+ # Sort by count descending if requested
223
+ if sort_results
224
+ entries.sort_by! { |entry| -entry[:count] }
225
+ end
226
+
227
+ # Print each entry
228
+ entries.each do |entry|
229
+ value_str = entry[:value].to_s
230
+ count = entry[:count]
231
+ puts " - #{value_str}: #{pretty_number(count)} #{pretty_percentage(count, n)}"
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,274 @@
1
+ require "benchmark"
2
+ require "json"
3
+ require "time"
4
+ require "open3"
5
+
6
+ module FeaturevisorCLI
7
+ module Commands
8
+ class Benchmark
9
+ def self.run(options)
10
+ new(options).run
11
+ end
12
+
13
+ def initialize(options)
14
+ @options = options
15
+ @project_path = options.project_directory_path
16
+ end
17
+
18
+ def run
19
+ # Validate required options
20
+ unless @options.environment
21
+ puts "Error: --environment is required for benchmark command"
22
+ exit 1
23
+ end
24
+
25
+ unless @options.feature
26
+ puts "Error: --feature is required for benchmark command"
27
+ exit 1
28
+ end
29
+
30
+ puts ""
31
+ puts "Running benchmark for feature \"#{@options.feature}\"..."
32
+ puts ""
33
+
34
+ # Parse context if provided
35
+ context = parse_context
36
+
37
+ puts "Building datafile containing all features for \"#{@options.environment}\"..."
38
+ datafile_build_start = Time.now
39
+
40
+ # Build datafile by executing the featurevisor build command
41
+ datafile = build_datafile(@options.environment)
42
+ datafile_build_duration = Time.now - datafile_build_start
43
+ datafile_build_duration_ms = (datafile_build_duration * 1000).round
44
+
45
+ puts "Datafile build duration: #{datafile_build_duration_ms}ms"
46
+
47
+ # Calculate datafile size
48
+ datafile_size = datafile.to_json.bytesize
49
+ puts "Datafile size: #{(datafile_size / 1024.0).round(2)} kB"
50
+
51
+ # Create SDK instance with the datafile
52
+ instance = create_instance(datafile)
53
+ puts "...SDK initialized"
54
+
55
+ puts ""
56
+ puts "Against context: #{context.to_json}"
57
+
58
+ # Run the appropriate benchmark
59
+ if @options.variation
60
+ puts "Evaluating variation #{@options.n} times..."
61
+ output = benchmark_feature_variation(instance, @options.feature, context, @options.n)
62
+ elsif @options.variable
63
+ puts "Evaluating variable \"#{@options.variable}\" #{@options.n} times..."
64
+ output = benchmark_feature_variable(instance, @options.feature, @options.variable, context, @options.n)
65
+ else
66
+ puts "Evaluating flag #{@options.n} times..."
67
+ output = benchmark_feature_flag(instance, @options.feature, context, @options.n)
68
+ end
69
+
70
+ puts ""
71
+
72
+ # Format the value output to match Go behavior
73
+ value_output = format_value(output[:value])
74
+ puts "Evaluated value : #{value_output}"
75
+ puts "Total duration : #{pretty_duration(output[:duration])}"
76
+ puts "Average duration: #{pretty_duration(output[:duration] / @options.n)}"
77
+ end
78
+
79
+ private
80
+
81
+ def parse_context
82
+ if @options.context
83
+ begin
84
+ context = JSON.parse(@options.context)
85
+ # Convert string keys to symbols for the SDK
86
+ context.transform_keys(&:to_sym)
87
+ rescue JSON::ParserError => e
88
+ puts "Error: Invalid JSON context: #{e.message}"
89
+ exit 1
90
+ end
91
+ else
92
+ {}
93
+ end
94
+ end
95
+
96
+ def build_datafile(environment)
97
+ puts "Building datafile for environment: #{environment}..."
98
+
99
+ # Build the command similar to Go implementation
100
+ 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
106
+
107
+ # Add inflate if specified
108
+ 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
114
+ end
115
+
116
+ command = command_parts.join(" ")
117
+
118
+ # Execute the command and capture output
119
+ datafile_output = execute_command(command)
120
+
121
+ # Parse the JSON output
122
+ begin
123
+ JSON.parse(datafile_output)
124
+ rescue JSON::ParserError => e
125
+ puts "Error: Failed to parse datafile JSON: #{e.message}"
126
+ puts "Command output: #{datafile_output}"
127
+ exit 1
128
+ end
129
+ end
130
+
131
+ def execute_command(command)
132
+ # Execute the command and capture stdout/stderr
133
+ stdout, stderr, status = Open3.capture3(command)
134
+
135
+ unless status.success?
136
+ puts "Error: Command failed with exit code #{status.exitstatus}"
137
+ puts "Command: #{command}"
138
+ puts "Stderr: #{stderr}" unless stderr.empty?
139
+ exit 1
140
+ end
141
+
142
+ stdout
143
+ end
144
+
145
+ def create_instance(datafile)
146
+ # Convert string keys to symbols for the SDK
147
+ symbolized_datafile = symbolize_keys(datafile)
148
+
149
+ # Create a real Featurevisor instance
150
+ instance = Featurevisor.create_instance(
151
+ log_level: get_logger_level
152
+ )
153
+
154
+ # Explicitly set the datafile
155
+ instance.set_datafile(symbolized_datafile)
156
+
157
+ instance
158
+ end
159
+
160
+ def get_logger_level
161
+ if @options.verbose
162
+ "debug"
163
+ elsif @options.quiet
164
+ "error"
165
+ else
166
+ "warn"
167
+ end
168
+ end
169
+
170
+ def symbolize_keys(obj)
171
+ case obj
172
+ when Hash
173
+ obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
174
+ when Array
175
+ obj.map { |item| symbolize_keys(item) }
176
+ else
177
+ obj
178
+ end
179
+ end
180
+
181
+ def benchmark_feature_flag(instance, feature_key, context, n)
182
+ start_time = Time.now
183
+
184
+ # Get the actual feature value from the SDK
185
+ value = instance.is_enabled(feature_key, context)
186
+
187
+ # Benchmark the evaluation
188
+ n.times do
189
+ instance.is_enabled(feature_key, context)
190
+ end
191
+
192
+ duration = Time.now - start_time
193
+
194
+ {
195
+ value: value,
196
+ duration: duration
197
+ }
198
+ end
199
+
200
+ def benchmark_feature_variation(instance, feature_key, context, n)
201
+ start_time = Time.now
202
+
203
+ # Get the actual feature variation from the SDK
204
+ value = instance.get_variation(feature_key, context)
205
+
206
+ # Benchmark the evaluation
207
+ n.times do
208
+ instance.get_variation(feature_key, context)
209
+ end
210
+
211
+ duration = Time.now - start_time
212
+
213
+ {
214
+ value: value,
215
+ duration: duration
216
+ }
217
+ end
218
+
219
+ def benchmark_feature_variable(instance, feature_key, variable_key, context, n)
220
+ start_time = Time.now
221
+
222
+ # Get the actual variable value from the SDK
223
+ value = instance.get_variable(feature_key, variable_key, context)
224
+
225
+ # Benchmark the evaluation
226
+ n.times do
227
+ instance.get_variable(feature_key, variable_key, context)
228
+ end
229
+
230
+ duration = Time.now - start_time
231
+
232
+ {
233
+ value: value,
234
+ duration: duration
235
+ }
236
+ end
237
+
238
+ def format_value(value)
239
+ if value.nil?
240
+ "null"
241
+ else
242
+ value.to_json
243
+ end
244
+ rescue JSON::GeneratorError
245
+ value.to_s
246
+ end
247
+
248
+ def pretty_duration(duration_seconds)
249
+ # Convert to milliseconds for consistency with Go implementation
250
+ ms = (duration_seconds * 1000).round
251
+
252
+ if ms == 0
253
+ return "0ms"
254
+ end
255
+
256
+ # Format like Go: hours, minutes, seconds, milliseconds
257
+ hours = ms / 3_600_000
258
+ ms = ms % 3_600_000
259
+ minutes = ms / 60_000
260
+ ms = ms % 60_000
261
+ seconds = ms / 1_000
262
+ ms = ms % 1_000
263
+
264
+ result = []
265
+ result << "#{hours}h" if hours > 0
266
+ result << "#{minutes}m" if minutes > 0
267
+ result << "#{seconds}s" if seconds > 0
268
+ result << "#{ms}ms" if ms > 0
269
+
270
+ result.join(" ")
271
+ end
272
+ end
273
+ end
274
+ end