featurevisor 0.2.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.
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "securerandom"
4
5
 
5
6
  module Featurevisor
6
7
  # Instance class for managing feature flag evaluations
7
8
  class Instance
8
- attr_reader :context, :logger, :sticky, :datafile_reader, :hooks_manager, :emitter
9
-
10
9
  # Empty datafile template
11
10
  EMPTY_DATAFILE = {
12
11
  schemaVersion: "2",
@@ -20,34 +19,44 @@ module Featurevisor
20
19
  # @option options [Hash, String] :datafile Datafile content or JSON string
21
20
  # @option options [Hash] :context Initial context
22
21
  # @option options [String] :log_level Log level
23
- # @option options [Logger] :logger Logger instance
24
22
  # @option options [Hash] :sticky Sticky features
25
- # @option options [Array<Hook>] :hooks Array of hooks
23
+ # @option options [Array<Hash, FeaturevisorModule>] :modules Array of modules
24
+ # @option options [Proc] :on_diagnostic Diagnostic handler
26
25
  def initialize(options = {})
27
26
  # from options
28
27
  @context = options[:context] || {}
29
- @logger = options[:logger] || Featurevisor.create_logger(level: options[:log_level] || "info")
30
- @hooks_manager = Featurevisor::Hooks::HooksManager.new(
31
- hooks: (options[:hooks] || []).map { |hook_data| Featurevisor::Hooks::Hook.new(hook_data) },
32
- logger: @logger
28
+ @logger = Logger.new(
29
+ level: options[:log_level] || "info",
30
+ handler: method(:handle_internal_log)
33
31
  )
32
+ @on_diagnostic = options[:on_diagnostic] || options[:onDiagnostic]
34
33
  @emitter = Featurevisor::Emitter.new
35
34
  @sticky = options[:sticky] || {}
35
+ @closed = false
36
+ @module_diagnostic_subscriptions = []
36
37
 
37
38
  # datafile
38
- @datafile_reader = Featurevisor::DatafileReader.new(
39
+ @datafile_reader = DatafileReader.new(
39
40
  datafile: EMPTY_DATAFILE,
40
41
  logger: @logger
41
42
  )
42
43
 
44
+ @modules_manager = Featurevisor::Modules::ModulesManager.new(
45
+ modules: options[:modules] || [],
46
+ report_diagnostic: method(:report_diagnostic),
47
+ module_api_factory: method(:create_module_api),
48
+ clear_module_diagnostic_subscriptions: method(:clear_module_diagnostic_subscriptions)
49
+ )
50
+
43
51
  if options[:datafile]
44
- @datafile_reader = Featurevisor::DatafileReader.new(
45
- datafile: options[:datafile].is_a?(String) ? JSON.parse(options[:datafile], symbolize_names: true) : options[:datafile],
46
- logger: @logger
47
- )
52
+ set_datafile(options[:datafile], true)
48
53
  end
49
54
 
50
- @logger.info("Featurevisor SDK initialized")
55
+ report_diagnostic(
56
+ level: "info",
57
+ code: "sdk_initialized",
58
+ message: "SDK initialized"
59
+ )
51
60
  end
52
61
 
53
62
  # Set the log level
@@ -56,22 +65,61 @@ module Featurevisor
56
65
  @logger.set_level(level)
57
66
  end
58
67
 
68
+ def handle_internal_log(level, message, details = nil)
69
+ details = (details || {}).dup
70
+ code = details[:reason] || details["reason"] || message
71
+ code = "deprecated_feature" if message == "feature is deprecated"
72
+ code = "deprecated_variable" if message == "variable is deprecated"
73
+ code = "feature_not_found" if message == "feature not found"
74
+ code = "variable_not_found" if message == "variable schema not found"
75
+ code = "no_variations" if message == "no variations"
76
+ code = "invalid_bucket_by" if message == "invalid bucketBy"
77
+ report_diagnostic(level: level, code: code.to_s, message: message, details: details)
78
+ end
79
+ private :handle_internal_log
80
+
59
81
  # Set the datafile
60
82
  # @param datafile [Hash, String] Datafile content or JSON string
61
- def set_datafile(datafile)
83
+ # @param replace [Boolean] Whether to replace instead of merge
84
+ def set_datafile(datafile, replace = false)
85
+ return if @closed
86
+
62
87
  begin
63
- new_datafile_reader = Featurevisor::DatafileReader.new(
64
- datafile: datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile,
88
+ parsed_datafile = if datafile.is_a?(String)
89
+ JSON.parse(datafile, symbolize_names: true)
90
+ elsif datafile.is_a?(Hash)
91
+ JSON.parse(JSON.generate(datafile), symbolize_names: true)
92
+ else
93
+ datafile
94
+ end
95
+ unless parsed_datafile.is_a?(Hash) && parsed_datafile[:schemaVersion].is_a?(String) &&
96
+ parsed_datafile[:revision].is_a?(String) && parsed_datafile[:segments].is_a?(Hash) &&
97
+ parsed_datafile[:features].is_a?(Hash)
98
+ raise ArgumentError, "Invalid datafile"
99
+ end
100
+ next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile_reader.get_datafile, parsed_datafile)
101
+ new_datafile_reader = DatafileReader.new(
102
+ datafile: next_datafile,
65
103
  logger: @logger
66
104
  )
67
105
 
68
- details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader)
106
+ details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader, replace)
69
107
  @datafile_reader = new_datafile_reader
70
108
 
71
- @logger.info("datafile set", details)
109
+ report_diagnostic(
110
+ level: "info",
111
+ code: "datafile_set",
112
+ message: "Datafile set",
113
+ details: details
114
+ )
72
115
  @emitter.trigger("datafile_set", details)
73
116
  rescue => e
74
- @logger.error("could not parse datafile", { error: e })
117
+ report_diagnostic(
118
+ level: "error",
119
+ code: "invalid_datafile",
120
+ message: "Could not parse datafile",
121
+ original_error: e
122
+ )
75
123
  end
76
124
  end
77
125
 
@@ -92,7 +140,12 @@ module Featurevisor
92
140
 
93
141
  params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace)
94
142
 
95
- @logger.info("sticky features set", params)
143
+ report_diagnostic(
144
+ level: "info",
145
+ code: "sticky_set",
146
+ message: "Sticky features set",
147
+ details: params
148
+ )
96
149
  @emitter.trigger("sticky_set", params)
97
150
  end
98
151
 
@@ -102,6 +155,26 @@ module Featurevisor
102
155
  @datafile_reader.get_revision
103
156
  end
104
157
 
158
+ def get_schema_version
159
+ @datafile_reader.get_schema_version
160
+ end
161
+
162
+ def get_segment(segment_key)
163
+ @datafile_reader.get_segment(segment_key)
164
+ end
165
+
166
+ def get_feature_keys
167
+ @datafile_reader.get_feature_keys
168
+ end
169
+
170
+ def get_variable_keys(feature_key)
171
+ @datafile_reader.get_variable_keys(feature_key)
172
+ end
173
+
174
+ def has_variations?(feature_key)
175
+ @datafile_reader.has_variations?(feature_key)
176
+ end
177
+
105
178
  # Get a feature by key
106
179
  # @param feature_key [String] Feature key
107
180
  # @return [Hash, nil] Feature data or nil if not found
@@ -109,11 +182,15 @@ module Featurevisor
109
182
  @datafile_reader.get_feature(feature_key)
110
183
  end
111
184
 
112
- # Add a hook
113
- # @param hook [Hook] Hook to add
114
- # @return [Proc, nil] Remove function or nil if hook already exists
115
- def add_hook(hook)
116
- @hooks_manager.add(hook)
185
+ # Add a module
186
+ # @param mod [Hash, FeaturevisorModule] Module to add
187
+ # @return [Proc, nil] Remove function or nil if module already exists
188
+ def add_module(mod)
189
+ @modules_manager.add(mod)
190
+ end
191
+
192
+ def remove_module(name_or_module)
193
+ @modules_manager.remove(name_or_module)
117
194
  end
118
195
 
119
196
  # Subscribe to an event
@@ -126,6 +203,9 @@ module Featurevisor
126
203
 
127
204
  # Close the instance
128
205
  def close
206
+ @closed = true
207
+ @modules_manager.close_all
208
+ @module_diagnostic_subscriptions = []
129
209
  @emitter.clear_all
130
210
  end
131
211
 
@@ -144,10 +224,15 @@ module Featurevisor
144
224
  replaced: replace
145
225
  })
146
226
 
147
- @logger.debug(replace ? "context replaced" : "context updated", {
148
- context: @context,
149
- replaced: replace
150
- })
227
+ report_diagnostic(
228
+ level: "debug",
229
+ code: "context_set",
230
+ message: replace ? "Context replaced" : "Context updated",
231
+ details: {
232
+ context: @context,
233
+ replaced: replace
234
+ }
235
+ )
151
236
  end
152
237
 
153
238
  # Get context
@@ -182,7 +267,7 @@ module Featurevisor
182
267
  # @param options [Hash] Override options
183
268
  # @return [Hash] Evaluation result
184
269
  def evaluate_flag(feature_key, context = {}, options = {})
185
- Featurevisor::Evaluate.evaluate_with_hooks(
270
+ Featurevisor::Evaluate.evaluate_with_modules(
186
271
  get_evaluation_dependencies(context, options).merge(
187
272
  type: "flag",
188
273
  feature_key: feature_key
@@ -211,7 +296,7 @@ module Featurevisor
211
296
  # @param options [Hash] Override options
212
297
  # @return [Hash] Evaluation result
213
298
  def evaluate_variation(feature_key, context = {}, options = {})
214
- Featurevisor::Evaluate.evaluate_with_hooks(
299
+ Featurevisor::Evaluate.evaluate_with_modules(
215
300
  get_evaluation_dependencies(context, options).merge(
216
301
  type: "variation",
217
302
  feature_key: feature_key
@@ -248,7 +333,7 @@ module Featurevisor
248
333
  # @param options [Hash] Override options
249
334
  # @return [Hash] Evaluation result
250
335
  def evaluate_variable(feature_key, variable_key, context = {}, options = {})
251
- Featurevisor::Evaluate.evaluate_with_hooks(
336
+ Featurevisor::Evaluate.evaluate_with_modules(
252
337
  get_evaluation_dependencies(context, options).merge(
253
338
  type: "variable",
254
339
  feature_key: feature_key,
@@ -417,9 +502,9 @@ module Featurevisor
417
502
  {
418
503
  context: get_context(context),
419
504
  logger: @logger,
420
- hooks_manager: @hooks_manager,
505
+ modules_manager: @modules_manager,
421
506
  datafile_reader: @datafile_reader,
422
- sticky: options[:sticky] ? { **(@sticky || {}), **options[:sticky] } : @sticky,
507
+ sticky: options[:__featurevisor_child_sticky] || @sticky,
423
508
  default_variation_value: options[:default_variation_value],
424
509
  default_variable_value: options[:default_variable_value]
425
510
  }
@@ -436,11 +521,12 @@ module Featurevisor
436
521
  when "string"
437
522
  value.is_a?(String) ? value : nil
438
523
  when "integer"
439
- value.is_a?(String) ? Integer(value, 10) : (value.is_a?(Integer) ? value : nil)
524
+ return value if value.is_a?(Integer)
525
+ value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : nil
440
526
  when "double"
441
- value.is_a?(String) ? Float(value) : (value.is_a?(Numeric) ? value.to_f : nil)
527
+ value.is_a?(Numeric) && value.finite? ? value.to_f : nil
442
528
  when "boolean"
443
- value == true
529
+ value == true || value == false ? value : nil
444
530
  when "array"
445
531
  value.is_a?(Array) ? value : nil
446
532
  when "object"
@@ -452,12 +538,112 @@ module Featurevisor
452
538
  rescue
453
539
  nil
454
540
  end
541
+
542
+ def merge_datafiles(previous, incoming)
543
+ previous ||= EMPTY_DATAFILE
544
+ incoming ||= EMPTY_DATAFILE
545
+
546
+ {
547
+ schemaVersion: incoming[:schemaVersion],
548
+ revision: incoming[:revision],
549
+ featurevisorVersion: incoming[:featurevisorVersion],
550
+ segments: {
551
+ **(previous[:segments] || {}),
552
+ **(incoming[:segments] || {})
553
+ },
554
+ features: {
555
+ **(previous[:features] || {}),
556
+ **(incoming[:features] || {})
557
+ }
558
+ }.compact
559
+ end
560
+
561
+ def create_module_api(mod)
562
+ instance = self
563
+ {
564
+ get_revision: -> { instance.get_revision },
565
+ on_diagnostic: lambda do |handler, options = {}|
566
+ subscription = {
567
+ id: SecureRandom.uuid,
568
+ module_id: mod.id,
569
+ handler: handler,
570
+ level: options[:level] || options[:log_level] || "info"
571
+ }
572
+ @module_diagnostic_subscriptions << subscription
573
+ -> { @module_diagnostic_subscriptions.reject! { |item| item[:id] == subscription[:id] } }
574
+ end,
575
+ report_diagnostic: ->(diagnostic) { report_diagnostic(diagnostic, mod) }
576
+ }
577
+ end
578
+
579
+ def clear_module_diagnostic_subscriptions(mod)
580
+ @module_diagnostic_subscriptions.reject! { |item| item[:module_id] == mod.id }
581
+ end
582
+
583
+ def report_diagnostic(diagnostic, source_module = nil)
584
+ diagnostic = (diagnostic || {}).dup
585
+ diagnostic[:level] ||= "info"
586
+ diagnostic[:module] = source_module.name if source_module && source_module.name
587
+ details = (diagnostic[:details] || {}).dup
588
+ legacy_module_name = diagnostic.delete(:module_name)
589
+ legacy_original_error = diagnostic.delete(:original_error)
590
+ diagnostic[:moduleName] = legacy_module_name if !diagnostic.key?(:moduleName) && !legacy_module_name.nil?
591
+ diagnostic[:originalError] = legacy_original_error if !diagnostic.key?(:originalError) && !legacy_original_error.nil?
592
+ diagnostic.each do |key, value|
593
+ next if %i[level code message module moduleName originalError details].include?(key)
594
+
595
+ details[key] = value
596
+ end
597
+ diagnostic.select! { |key, _| %i[level code message module moduleName originalError details].include?(key) }
598
+ diagnostic[:details] = details
599
+
600
+ @module_diagnostic_subscriptions.dup.each do |subscription|
601
+ next if source_module && subscription[:module_id] == source_module.id
602
+ next unless should_report_diagnostic?(diagnostic[:level], subscription[:level])
603
+
604
+ begin
605
+ subscription[:handler].call(diagnostic)
606
+ rescue => e
607
+ Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}")
608
+ end
609
+ end
610
+
611
+ if @on_diagnostic
612
+ if should_report_diagnostic?(diagnostic[:level], @logger.level)
613
+ begin
614
+ @on_diagnostic.call(diagnostic)
615
+ rescue => e
616
+ Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}")
617
+ end
618
+ end
619
+ else
620
+ Logger.new(level: @logger.level).log(
621
+ diagnostic[:level],
622
+ diagnostic[:message],
623
+ diagnostic
624
+ )
625
+ end
626
+
627
+ if diagnostic[:level] == "error"
628
+ @emitter.trigger("error", diagnostic: diagnostic)
629
+ end
630
+ end
631
+
632
+ def should_report_diagnostic?(diagnostic_level, subscriber_level)
633
+ levels = Featurevisor::LOG_LEVELS
634
+ diagnostic_index = levels.index(diagnostic_level || "info")
635
+ subscriber_index = levels.index(subscriber_level || "info")
636
+
637
+ return false if diagnostic_index.nil? || subscriber_index.nil?
638
+
639
+ subscriber_index >= diagnostic_index
640
+ end
455
641
  end
456
642
 
457
643
  # Create a new Featurevisor instance
458
644
  # @param options [Hash] Instance options
459
645
  # @return [Instance] New instance
460
- def self.create_instance(options = {})
646
+ def self.create_featurevisor(options = {})
461
647
  Instance.new(options)
462
648
  end
463
649
  end
@@ -113,13 +113,6 @@ module Featurevisor
113
113
  end
114
114
  end
115
115
 
116
- # Create a new logger instance
117
- # @param options [Hash] Logger options
118
- # @return [Logger] New logger instance
119
- def self.create_logger(options = {})
120
- Logger.new(options)
121
- end
122
-
123
116
  # Default log handler function
124
117
  # @param level [String] Log level
125
118
  # @param message [String] Log message
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Featurevisor
6
+ # Modules extend evaluation behavior and SDK lifecycle.
7
+ module Modules
8
+ class FeaturevisorModule
9
+ attr_reader :id, :name
10
+
11
+ def initialize(options = {})
12
+ @id = SecureRandom.uuid
13
+ @name = options[:name]
14
+ @setup = options[:setup]
15
+ @before = options[:before]
16
+ @bucket_key = options[:bucket_key]
17
+ @bucket_value = options[:bucket_value]
18
+ @after = options[:after]
19
+ @close = options[:close]
20
+ end
21
+
22
+ def call_setup(api)
23
+ @setup.call(api) if @setup
24
+ end
25
+
26
+ def call_before(options)
27
+ return options unless @before
28
+
29
+ @before.call(options)
30
+ end
31
+
32
+ def call_bucket_key(options)
33
+ return options[:bucket_key] unless @bucket_key
34
+
35
+ @bucket_key.call(options)
36
+ end
37
+
38
+ def call_bucket_value(options)
39
+ return options[:bucket_value] unless @bucket_value
40
+
41
+ @bucket_value.call(options)
42
+ end
43
+
44
+ def call_after(evaluation, options)
45
+ return evaluation unless @after
46
+
47
+ @after.call(evaluation, options)
48
+ end
49
+
50
+ def call_close
51
+ @close.call if @close
52
+ end
53
+ end
54
+
55
+ class ModulesManager
56
+ attr_reader :modules
57
+
58
+ def initialize(options = {})
59
+ @modules = []
60
+ @report_diagnostic = options[:report_diagnostic]
61
+ @module_api_factory = options[:module_api_factory]
62
+ @clear_module_diagnostic_subscriptions = options[:clear_module_diagnostic_subscriptions]
63
+
64
+ (options[:modules] || []).each do |mod|
65
+ add(mod)
66
+ end
67
+ end
68
+
69
+ def add(mod)
70
+ mod = FeaturevisorModule.new(mod) if mod.is_a?(Hash)
71
+ return nil unless mod
72
+
73
+ if mod.name && !mod.name.to_s.empty? && @modules.any? { |existing| existing.name == mod.name }
74
+ report(
75
+ {
76
+ level: "error",
77
+ code: "duplicate_module",
78
+ message: "Duplicate module name",
79
+ module_name: mod.name
80
+ },
81
+ nil
82
+ )
83
+ return nil
84
+ end
85
+
86
+ begin
87
+ mod.call_setup(@module_api_factory.call(mod)) if @module_api_factory
88
+ rescue => e
89
+ @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions
90
+ report(
91
+ {
92
+ level: "error",
93
+ code: "module_setup_error",
94
+ message: "Module setup failed",
95
+ module_name: mod.name,
96
+ original_error: e
97
+ },
98
+ nil
99
+ )
100
+ close_module(mod)
101
+ return nil
102
+ end
103
+ @modules << mod
104
+
105
+ -> { remove(mod) }
106
+ end
107
+
108
+ def remove(name_or_module)
109
+ removed = []
110
+ @modules = @modules.reject do |mod|
111
+ matches = name_or_module.equal?(mod) || mod.name == name_or_module
112
+ removed << mod if matches
113
+ matches
114
+ end
115
+
116
+ removed.each do |mod|
117
+ @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions
118
+ close_module(mod)
119
+ end
120
+ end
121
+
122
+ def get_all
123
+ @modules
124
+ end
125
+
126
+ def run_before_modules(options)
127
+ @modules.reduce(options) do |result, mod|
128
+ mod.call_before(result)
129
+ end
130
+ end
131
+
132
+ def run_bucket_key_modules(options)
133
+ bucket_key = options[:bucket_key]
134
+ @modules.each do |mod|
135
+ bucket_key = mod.call_bucket_key(options.merge(bucket_key: bucket_key))
136
+ end
137
+ bucket_key
138
+ end
139
+
140
+ def run_bucket_value_modules(options)
141
+ bucket_value = options[:bucket_value]
142
+ @modules.each do |mod|
143
+ bucket_value = mod.call_bucket_value(options.merge(bucket_value: bucket_value))
144
+ end
145
+ bucket_value
146
+ end
147
+
148
+ def run_after_modules(evaluation, options)
149
+ @modules.reduce(evaluation) do |result, mod|
150
+ mod.call_after(result, options)
151
+ end
152
+ end
153
+
154
+ def close_all
155
+ @modules.each do |mod|
156
+ @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions
157
+ close_module(mod)
158
+ end
159
+ @modules = []
160
+ end
161
+
162
+ private
163
+
164
+ def close_module(mod)
165
+ mod.call_close
166
+ rescue => e
167
+ report(
168
+ {
169
+ level: "error",
170
+ code: "module_close_error",
171
+ message: "Module close failed",
172
+ module_name: mod.name,
173
+ original_error: e
174
+ },
175
+ nil
176
+ )
177
+ end
178
+
179
+ def report(diagnostic, mod)
180
+ @report_diagnostic.call(diagnostic, mod) if @report_diagnostic
181
+ end
182
+ end
183
+ end
184
+ end
@@ -1,3 +1,3 @@
1
1
  module Featurevisor
2
- VERSION = "0.2.0"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/featurevisor.rb CHANGED
@@ -6,7 +6,7 @@ require_relative "featurevisor/emitter"
6
6
  require_relative "featurevisor/conditions"
7
7
  require_relative "featurevisor/datafile_reader"
8
8
  require_relative "featurevisor/bucketer"
9
- require_relative "featurevisor/hooks"
9
+ require_relative "featurevisor/modules"
10
10
  require_relative "featurevisor/evaluate"
11
11
  require_relative "featurevisor/instance"
12
12
  require_relative "featurevisor/child_instance"
@@ -14,4 +14,7 @@ require_relative "featurevisor/events"
14
14
 
15
15
  module Featurevisor
16
16
  class Error < StandardError; end
17
+
18
+ private_constant :DatafileReader
19
+ private_constant :Logger
17
20
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featurevisor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fahad Heylaal
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: benchmark
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: rspec
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -60,9 +74,9 @@ files:
60
74
  - lib/featurevisor/emitter.rb
61
75
  - lib/featurevisor/evaluate.rb
62
76
  - lib/featurevisor/events.rb
63
- - lib/featurevisor/hooks.rb
64
77
  - lib/featurevisor/instance.rb
65
78
  - lib/featurevisor/logger.rb
79
+ - lib/featurevisor/modules.rb
66
80
  - lib/featurevisor/murmurhash.rb
67
81
  - lib/featurevisor/version.rb
68
82
  homepage: https://featurevisor.com