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
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
|