From 3deb274e99f10199be4a956f4b2fc1dc6da48b07 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 20:54:51 +0200 Subject: [PATCH 01/16] v3 --- Makefile | 6 +- README.md | 122 +++++++++--- bin/cli.rb | 6 +- bin/commands/assess_distribution.rb | 4 - bin/commands/benchmark.rb | 12 +- bin/commands/test.rb | 140 ++++++-------- lib/featurevisor.rb | 2 +- lib/featurevisor/datafile_reader.rb | 13 +- lib/featurevisor/emitter.rb | 2 +- lib/featurevisor/evaluate.rb | 39 ++-- lib/featurevisor/events.rb | 5 +- lib/featurevisor/hooks.rb | 159 --------------- lib/featurevisor/instance.rb | 154 ++++++++++++--- lib/featurevisor/modules.rb | 153 +++++++++++++++ spec/cli_spec.rb | 10 +- spec/emitter_spec.rb | 2 +- spec/evaluate_spec.rb | 62 +++--- spec/events_spec.rb | 9 +- spec/hooks_spec.rb | 236 ---------------------- spec/instance_spec.rb | 177 ++++++++++++++++- spec/modules_spec.rb | 290 ++++++++++++++++++++++++++++ spec/test_command_spec.rb | 36 +--- 22 files changed, 989 insertions(+), 650 deletions(-) delete mode 100644 lib/featurevisor/hooks.rb create mode 100644 lib/featurevisor/modules.rb delete mode 100644 spec/hooks_spec.rb create mode 100644 spec/modules_spec.rb diff --git a/Makefile b/Makefile index 135068a..c55d7bd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install build test setup-monorepo update-monorepo +.PHONY: install build test test-example-1 setup-monorepo update-monorepo install: bundle install @@ -9,6 +9,10 @@ build: test: bundle exec rspec spec/ +test-example-1: + bundle exec rspec spec/ + bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + setup-monorepo: mkdir -p monorepo if [ ! -d "monorepo/.git" ]; then \ diff --git a/README.md b/README.md index f3e5a21..8a21b52 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Featurevisor Ruby SDK -This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v2.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications. +This is a port of Featurevisor [JavaScript SDK](https://featurevisor.com/docs/sdks/javascript/) v3.x to Ruby, providing a way to evaluate feature flags, variations, and variables in your Ruby applications. -This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 projects and above. +This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Table of contents @@ -33,10 +33,12 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v2.0 proje - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) + - [`error`](#error) - [Evaluation details](#evaluation-details) -- [Hooks](#hooks) - - [Defining a hook](#defining-a-hook) - - [Registering hooks](#registering-hooks) +- [Diagnostics](#diagnostics) +- [Modules](#modules) + - [Defining a module](#defining-a-module) + - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) - [CLI usage](#cli-usage) @@ -362,6 +364,18 @@ f.set_datafile(json_string) **Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself. +By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's current datafile: + +- top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile +- `segments` are merged, with incoming entries overriding existing ones +- `features` are merged, with incoming entries overriding existing ones + +To fully replace the stored datafile, pass `true` as the second argument: + +```ruby +f.set_datafile(datafile_content, true) +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. @@ -493,6 +507,8 @@ The `features` array will contain keys of features that have either been: compared to the previous datafile content that existed in the SDK instance. +The event also includes `replaced`, which is `true` when the datafile replaced the previous content instead of merging into it. + ### `context_set` ```ruby @@ -515,6 +531,19 @@ unsubscribe = f.on('sticky_set') do |event| end ``` +### `error` + +```ruby +unsubscribe = f.on('error') do |event| + code = event[:code] + message = event[:message] + + puts "Featurevisor error: #{code} #{message}" +end +``` + +The `error` event is emitted for diagnostics reported with `level: "error"` or `level: "fatal"`. + ## Evaluation details Besides logging with debug level enabled, you can also get more details about how the feature variations and variables are evaluated in the runtime against given context: @@ -547,22 +576,49 @@ And optionally these properties depending on whether you are evaluating a featur - `variable_value`: the variable value - `variable_schema`: the variable schema -## Hooks +## Diagnostics + +Diagnostics provide structured SDK and module events for observability. + +```ruby +f = Featurevisor.create_instance( + on_diagnostic: ->(diagnostic) { + level = diagnostic[:level] + code = diagnostic[:code] + message = diagnostic[:message] + + puts "[#{level}] #{code}: #{message}" + } +) +``` + +If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. -Hooks allow you to intercept the evaluation process and customize it further as per your needs. +## Modules -### Defining a hook +Modules allow you to intercept the evaluation process and customize SDK behavior. -A hook is a simple hash with a unique required `name` and optional functions: +### Defining a module + +A module is a simple hash with a unique recommended `name` and optional lifecycle functions: ```ruby require 'featurevisor' -my_custom_hook = { - # only required property - name: 'my-custom-hook', +my_custom_module = { + # recommended, and used for duplicate detection/removal + name: 'my-custom-module', + + # rest of the properties below are all optional per module - # rest of the properties below are all optional per hook + # setup once, when the module is registered + setup: ->(api) { + revision = api[:get_revision].call + + api[:on_diagnostic].call(->(diagnostic) { + puts diagnostic[:message] + }) + }, # before evaluation before: ->(options) { @@ -592,26 +648,43 @@ my_custom_hook = { bucket_value: ->(options) { # return custom bucket value options[:bucket_value] + }, + + # cleanup when module is removed or SDK is closed + close: -> { + # cleanup here } } ``` -### Registering hooks +The module API passed to `setup` exposes: + +- `get_revision` +- `on_diagnostic` +- `report_diagnostic` + +### Registering modules -You can register hooks at the time of SDK initialization: +You can register modules at the time of SDK initialization: ```ruby require 'featurevisor' f = Featurevisor.create_instance( - hooks: [my_custom_hook] + modules: [my_custom_module] ) ``` Or after initialization: ```ruby -f.add_hook(my_custom_hook) +remove_module = f.add_module(my_custom_module) + +# remove later by calling the returned function +remove_module.call + +# or remove by name +f.remove_module('my-custom-module') ``` ## Child instance @@ -687,24 +760,17 @@ $ bundle exec featurevisor test \ --quiet|--verbose \ --onlyFailures \ --keyPattern="myFeatureKey" \ - --assertionPattern="#1" \ - --with-scopes \ - --with-tags + --assertionPattern="#1" ``` -`--with-scopes` and `--with-tags` make the Ruby test runner build scoped/tagged datafiles in memory (via `npx featurevisor build --json`) and evaluate matching assertions against those exact datafiles. - -If an assertion references `scope` and `--with-scopes` is not provided, the runner still evaluates the assertion by merging that scope's configured context into the assertion context (without building scoped datafiles). - -For compatibility, camelCase aliases are also supported: `--withScopes` and `--withTags`. +The Ruby test runner builds base datafiles and Target datafiles in memory via `npx featurevisor build --json`. When an assertion contains `target`, it is evaluated against the matching Target datafile. ### Test against local monorepo's example-1 ```bash $ cd /absolute/path/to/featurevisor-ruby -$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 -$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-scopes -$ bundle exec featurevisor test --projectDirectoryPath=./monorepo/examples/example-1 --with-tags +$ bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures +$ make test-example-1 ``` ### Benchmark diff --git a/bin/cli.rb b/bin/cli.rb index 1c21609..8c95293 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -82,15 +82,15 @@ def self.parse(args) options.show_datafile = true end - opts.on("--schemaVersion=VERSION", "Schema version") do |v| + opts.on("--schemaVersion=VERSION", "--schema-version=VERSION", "Legacy schema version option accepted and ignored") do |v| options.schema_version = v end - opts.on("--with-scopes", "--withScopes", "Test scoped assertions against scoped datafiles") do + opts.on("--with-scopes", "--withScopes", "Legacy scope option accepted and ignored") do options.with_scopes = true end - opts.on("--with-tags", "--withTags", "Test tagged assertions against tagged datafiles") do + opts.on("--with-tags", "--withTags", "Legacy tag option accepted and ignored") do options.with_tags = true end diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index cf72f00..83445e9 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -127,10 +127,6 @@ def build_datafile(environment) # Build the command similar to Go implementation command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - if @options.schema_version - command_parts << "--schemaVersion=#{@options.schema_version}" - end - if @options.inflate command_parts << "--inflate=#{@options.inflate}" end diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 987790d..4e435a3 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -96,21 +96,11 @@ def parse_context def build_datafile(environment) puts "Building datafile for environment: #{environment}..." - # Build the command similar to Go implementation command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] - # Add schema version if specified - if @options.schema_version && !@options.schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--json"] - end - # Add inflate if specified if @options.inflate && @options.inflate > 0 - if @options.schema_version && !@options.schema_version.empty? - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--schemaVersion=#{@options.schema_version}", "--inflate=#{@options.inflate}", "--json"] - else - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{@options.inflate}", "--json"] - end + command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{@options.inflate}", "--json"] end command = command_parts.join(" ") diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 6d96d79..2b9f371 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -21,16 +21,11 @@ def run # Get project configuration config = get_config environments = get_environments(config) + targets = get_targets segments_by_key = get_segments - # Use CLI schemaVersion option or fallback to config - schema_version = @options.schema_version - if schema_version.nil? || schema_version.empty? - schema_version = config[:schemaVersion] - end - - # Build datafiles for all environments (+ scoped/tagged variants) - datafiles_by_key = build_datafiles(config, environments, schema_version, @options.inflate) + # Build base and Target datafiles for all environments. + datafiles_by_key = build_datafiles(environments, targets, @options.inflate) puts "" @@ -84,6 +79,34 @@ def get_segments end end + def get_targets + puts "Getting targets..." + command = "(cd #{Shellwords.escape(@project_path)} && npx featurevisor list --targets --json)" + targets_output = execute_command(command) + + begin + targets = JSON.parse(targets_output, symbolize_names: true) + + if targets.is_a?(Array) + targets.filter_map do |target| + if target.is_a?(Hash) + target[:name] || target[:key] + else + target + end + end + elsif targets.is_a?(Hash) + targets.keys + else + [] + end + rescue JSON::ParserError => e + puts "Error: Failed to parse targets JSON: #{e.message}" + puts "Command output: #{targets_output}" + exit 1 + end + end + def get_environments(config) environments = config[:environments] @@ -97,23 +120,15 @@ def base_datafile_key(environment) environment == false ? false : environment end - def scoped_datafile_key(environment, scope_name) - if environment == false - "scope-#{scope_name}" - else - "#{environment}-scope-#{scope_name}" - end - end - - def tagged_datafile_key(environment, tag) + def target_datafile_key(environment, target) if environment == false - "tag-#{tag}" + "false-target-#{target}" else - "#{environment}-tag-#{tag}" + "#{environment}-target-#{target}" end end - def build_datafiles(config, environments, schema_version, inflate) + def build_datafiles(environments, targets, inflate) datafiles_by_key = {} environments.each do |environment| @@ -122,61 +137,35 @@ def build_datafiles(config, environments, schema_version, inflate) datafiles_by_key[base_datafile_key(environment)] = build_single_datafile( environment: environment, - schema_version: schema_version, inflate: inflate ) - if @options.with_scopes && config[:scopes].is_a?(Array) - config[:scopes].each do |scope| - next unless scope[:name] - - puts "Building scoped datafile for scope: #{scope[:name]}..." - datafiles_by_key[scoped_datafile_key(environment, scope[:name])] = build_single_datafile( - environment: environment, - schema_version: schema_version, - inflate: inflate, - scope: scope[:name] - ) - end - end - - if @options.with_tags && config[:tags].is_a?(Array) - config[:tags].each do |tag| - puts "Building tagged datafile for tag: #{tag}..." - datafiles_by_key[tagged_datafile_key(environment, tag)] = build_single_datafile( - environment: environment, - schema_version: schema_version, - inflate: inflate, - tag: tag - ) - end + targets.each do |target| + puts "Building datafile for target: #{target}..." + datafiles_by_key[target_datafile_key(environment, target)] = build_single_datafile( + environment: environment, + inflate: inflate, + target: target + ) end end datafiles_by_key end - def build_single_datafile(environment:, schema_version:, inflate:, scope: nil, tag: nil) + def build_single_datafile(environment:, inflate:, target: nil) command_parts = ["npx", "featurevisor", "build", "--json"] if environment != false && !environment.nil? command_parts << "--environment=#{Shellwords.escape(environment.to_s)}" end - if schema_version && !schema_version.empty? - command_parts << "--schemaVersion=#{Shellwords.escape(schema_version.to_s)}" - end - if inflate && inflate > 0 command_parts << "--inflate=#{inflate}" end - if scope - command_parts << "--scope=#{Shellwords.escape(scope.to_s)}" - end - - if tag - command_parts << "--tag=#{Shellwords.escape(tag.to_s)}" + if target + command_parts << "--target=#{Shellwords.escape(target.to_s)}" end command = "(cd #{Shellwords.escape(@project_path)} && #{command_parts.join(' ')})" @@ -224,29 +213,15 @@ def get_tests end end - def get_scope_context(config, scope_name) - return {} unless scope_name && config[:scopes].is_a?(Array) - - scope = config[:scopes].find { |s| s[:name] == scope_name } - return {} unless scope && scope[:context].is_a?(Hash) - - parse_context(scope[:context]) - end - def resolve_datafile_for_assertion(assertion, datafiles_by_key) environment = assertion.key?(:environment) ? assertion[:environment] : false environment = false if environment.nil? - scoped_key = assertion[:scope] ? scoped_datafile_key(environment, assertion[:scope]) : nil - tagged_key = assertion[:tag] ? tagged_datafile_key(environment, assertion[:tag]) : nil + target_key = assertion[:target] ? target_datafile_key(environment, assertion[:target]) : nil base_key = base_datafile_key(environment) - if scoped_key && datafiles_by_key.key?(scoped_key) - return datafiles_by_key[scoped_key] - end - - if tagged_key && datafiles_by_key.key?(tagged_key) - return datafiles_by_key[tagged_key] + if target_key && datafiles_by_key.key?(target_key) + return datafiles_by_key[target_key] end datafiles_by_key[base_key] @@ -259,15 +234,15 @@ def create_tester_instance(datafile, level, assertion) datafile: datafile, sticky: sticky, log_level: level, - hooks: [ + modules: [ { - name: "tester-hook", + name: "test-module", bucket_value: ->(options) do at = assertion[:at] if at.is_a?(Numeric) (at * 1000).to_i else - options.bucket_value + options[:bucket_value] end end } @@ -297,19 +272,13 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) if datafile.nil? test_result = { has_error: true, - errors: " ✘ no datafile found for assertion scope/tag/environment combination\n", + errors: " ✘ no datafile found for assertion target/environment combination\n", duration: 0 } end if datafile instance = create_tester_instance(datafile, level, assertion) - scope_context = {} - - if assertion[:scope] && !@options.with_scopes - # If not using scoped datafiles, mimic JS behavior by merging scope context. - scope_context = get_scope_context(config, assertion[:scope]) - end # Show datafile if requested if @options.show_datafile @@ -318,7 +287,7 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) puts "" end - test_result = run_test_feature(assertion, test[:feature], instance, level, scope_context) + test_result = run_test_feature(assertion, test[:feature], instance, level) end elsif test[:segment] segment_key = test[:segment] @@ -366,9 +335,8 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) end end - def run_test_feature(assertion, feature_key, instance, level, scope_context = {}) + def run_test_feature(assertion, feature_key, instance, level) context = parse_context(assertion[:context]) - context = { **scope_context, **context } if scope_context && !scope_context.empty? sticky = parse_sticky(assertion[:sticky]) # Set context and sticky for this assertion diff --git a/lib/featurevisor.rb b/lib/featurevisor.rb index b8abcd7..f591027 100644 --- a/lib/featurevisor.rb +++ b/lib/featurevisor.rb @@ -6,7 +6,7 @@ require_relative "featurevisor/conditions" require_relative "featurevisor/datafile_reader" require_relative "featurevisor/bucketer" -require_relative "featurevisor/hooks" +require_relative "featurevisor/modules" require_relative "featurevisor/evaluate" require_relative "featurevisor/instance" require_relative "featurevisor/child_instance" diff --git a/lib/featurevisor/datafile_reader.rb b/lib/featurevisor/datafile_reader.rb index 7a8f91f..5a4d2c7 100644 --- a/lib/featurevisor/datafile_reader.rb +++ b/lib/featurevisor/datafile_reader.rb @@ -5,7 +5,7 @@ module Featurevisor # DatafileReader class for reading and processing Featurevisor datafiles class DatafileReader - attr_reader :schema_version, :revision, :segments, :features, :logger, :regex_cache + attr_reader :schema_version, :revision, :featurevisor_version, :segments, :features, :logger, :regex_cache # Initialize a new DatafileReader # @param options [Hash] Options hash containing datafile and logger @@ -17,6 +17,7 @@ def initialize(options) @schema_version = datafile[:schemaVersion] @revision = datafile[:revision] + @featurevisor_version = datafile[:featurevisorVersion] @segments = (datafile[:segments] || {}).transform_keys(&:to_sym) @features = (datafile[:features] || {}).transform_keys(&:to_sym) @@ -65,6 +66,16 @@ def get_schema_version @schema_version end + def get_datafile + { + schemaVersion: @schema_version, + revision: @revision, + featurevisorVersion: @featurevisor_version, + segments: @segments, + features: @features + }.compact + end + # Get a segment by key # @param segment_key [String] Segment key to retrieve # @return [Hash, nil] Segment data or nil if not found diff --git a/lib/featurevisor/emitter.rb b/lib/featurevisor/emitter.rb index 8e1bb7c..0fd56cf 100644 --- a/lib/featurevisor/emitter.rb +++ b/lib/featurevisor/emitter.rb @@ -2,7 +2,7 @@ module Featurevisor # Event names for the emitter - EVENT_NAMES = %w[datafile_set context_set sticky_set].freeze + EVENT_NAMES = %w[datafile_set context_set sticky_set error].freeze # Event emitter class for handling event subscriptions and triggers class Emitter diff --git a/lib/featurevisor/evaluate.rb b/lib/featurevisor/evaluate.rb index 725243f..9d8c03e 100644 --- a/lib/featurevisor/evaluate.rb +++ b/lib/featurevisor/evaluate.rb @@ -36,20 +36,17 @@ module EvaluationReason # Evaluation module for feature flag evaluation module Evaluate - # Evaluate with hooks + # Evaluate with modules # @param options [Hash] Evaluation options # @return [Hash] Evaluation result - def self.evaluate_with_hooks(options) + def self.evaluate_with_modules(options) begin - hooks_manager = options[:hooks_manager] - hooks = hooks_manager.get_all + modules_manager = options[:modules_manager] - # Run before hooks + # Run before modules result_options = options - hooks.each do |hook| - if hook.respond_to?(:call_before) - result_options = hook.call_before(result_options) - end + if modules_manager + result_options = modules_manager.run_before_modules(result_options) end # Evaluate @@ -69,11 +66,9 @@ def self.evaluate_with_hooks(options) evaluation[:variable_value] = options[:default_variable_value] end - # Run after hooks - hooks.each do |hook| - if hook.respond_to?(:call_after) - evaluation = hook.call_after(evaluation, result_options) - end + # Run after modules + if modules_manager + evaluation = modules_manager.run_after_modules(evaluation, result_options) end evaluation @@ -108,9 +103,7 @@ def self.evaluate(options) logger = options[:logger] datafile_reader = options[:datafile_reader] sticky = options[:sticky] - hooks_manager = options[:hooks_manager] - - hooks = hooks_manager.get_all + modules_manager = options[:modules_manager] evaluation = nil begin @@ -422,24 +415,24 @@ def self.evaluate(options) logger: logger }) - # Run bucket key hooks - bucket_key = hooks_manager.run_bucket_key_hooks({ + # Run bucket key modules + bucket_key = modules_manager.run_bucket_key_modules({ feature_key: feature_key, context: context, bucket_by: feature[:bucketBy], bucket_key: bucket_key - }) + }) if modules_manager # bucketValue bucket_value = Featurevisor::Bucketer.get_bucketed_number(bucket_key) - # Run bucket value hooks - bucket_value = hooks_manager.run_bucket_value_hooks({ + # Run bucket value modules + bucket_value = modules_manager.run_bucket_value_modules({ feature_key: feature_key, bucket_key: bucket_key, context: context, bucket_value: bucket_value - }) + }) if modules_manager matched_traffic = nil matched_allocation = nil diff --git a/lib/featurevisor/events.rb b/lib/featurevisor/events.rb index bf0e89b..1190a46 100644 --- a/lib/featurevisor/events.rb +++ b/lib/featurevisor/events.rb @@ -24,7 +24,7 @@ def self.get_params_for_sticky_set_event(previous_sticky = {}, new_sticky = {}, # @param previous_reader [DatafileReader] Previous datafile reader # @param new_reader [DatafileReader] New datafile reader # @return [Hash] Event parameters - def self.get_params_for_datafile_set_event(previous_reader, new_reader) + def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace = false) previous_revision = previous_reader.get_revision previous_feature_keys = previous_reader.get_feature_keys @@ -69,7 +69,8 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader) revision: new_revision, previous_revision: previous_revision, revision_changed: previous_revision != new_revision, - features: all_affected_features + features: all_affected_features, + replaced: replace } end end diff --git a/lib/featurevisor/hooks.rb b/lib/featurevisor/hooks.rb deleted file mode 100644 index 8e85702..0000000 --- a/lib/featurevisor/hooks.rb +++ /dev/null @@ -1,159 +0,0 @@ -# frozen_string_literal: true - -module Featurevisor - # Hooks module for extending evaluation behavior - module Hooks - # Hook interface for extending evaluation behavior - class Hook - attr_reader :name - - # Initialize a new hook - # @param options [Hash] Hook options - # @option options [String] :name Hook name - # @option options [Proc, nil] :before Before evaluation hook - # @option options [Proc, nil] :bucket_key Bucket key configuration hook - # @option options [Proc, nil] :bucket_value Bucket value configuration hook - # @option options [Proc, nil] :after After evaluation hook - def initialize(options) - @name = options[:name] - @before = options[:before] - @bucket_key = options[:bucket_key] - @bucket_value = options[:bucket_value] - @after = options[:after] - end - - # Call the before hook if defined - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation options - def call_before(options) - return options unless @before - - @before.call(options) - end - - # Call the bucket key hook if defined - # @param options [Hash] Bucket key options - # @return [String] Modified bucket key - def call_bucket_key(options) - return options[:bucket_key] unless @bucket_key - - @bucket_key.call(options) - end - - # Call the bucket value hook if defined - # @param options [Hash] Bucket value options - # @return [Integer] Modified bucket value - def call_bucket_value(options) - return options[:bucket_value] unless @bucket_value - - @bucket_value.call(options) - end - - # Call the after hook if defined - # @param evaluation [Hash] Evaluation result - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation result - def call_after(evaluation, options) - return evaluation unless @after - - @after.call(evaluation, options) - end - end - - # HooksManager class for managing hooks - class HooksManager - attr_reader :hooks, :logger - - # Initialize a new HooksManager - # @param options [Hash] Options hash containing hooks and logger - # @option options [Array] :hooks Array of hooks - # @option options [Logger] :logger Logger instance - def initialize(options) - @logger = options[:logger] - @hooks = [] - - if options[:hooks] - options[:hooks].each do |hook| - add(hook) - end - end - end - - # Add a hook to the manager - # @param hook [Hook] Hook to add - # @return [Proc, nil] Remove function or nil if hook already exists - def add(hook) - if @hooks.any? { |existing_hook| existing_hook.name == hook.name } - @logger.error("Hook with name \"#{hook.name}\" already exists.", { - name: hook.name, - hook: hook - }) - - return nil - end - - @hooks << hook - - # Return a remove function - -> { remove(hook.name) } - end - - # Remove a hook by name - # @param name [String] Hook name to remove - def remove(name) - @hooks = @hooks.reject { |hook| hook.name == name } - end - - # Get all hooks - # @return [Array] Array of all hooks - def get_all - @hooks - end - - # Run before hooks - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation options - def run_before_hooks(options) - result = options - @hooks.each do |hook| - result = hook.call_before(result) - end - result - end - - # Run bucket key hooks - # @param options [Hash] Bucket key options - # @return [String] Modified bucket key - def run_bucket_key_hooks(options) - bucket_key = options[:bucket_key] - @hooks.each do |hook| - bucket_key = hook.call_bucket_key(options.merge(bucket_key: bucket_key)) - end - bucket_key - end - - # Run bucket value hooks - # @param options [Hash] Bucket value options - # @return [Integer] Modified bucket value - def run_bucket_value_hooks(options) - bucket_value = options[:bucket_value] - @hooks.each do |hook| - bucket_value = hook.call_bucket_value(options.merge(bucket_value: bucket_value)) - end - bucket_value - end - - # Run after hooks - # @param evaluation [Hash] Evaluation result - # @param options [Hash] Evaluation options - # @return [Hash] Modified evaluation result - def run_after_hooks(evaluation, options) - result = evaluation - @hooks.each do |hook| - result = hook.call_after(result, options) - end - result - end - end - end -end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 3e8a66d..66190f0 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true require "json" +require "securerandom" module Featurevisor # Instance class for managing feature flag evaluations class Instance - attr_reader :context, :logger, :sticky, :datafile_reader, :hooks_manager, :emitter + attr_reader :context, :logger, :sticky, :datafile_reader, :modules_manager, :emitter # Empty datafile template EMPTY_DATAFILE = { @@ -22,17 +23,17 @@ class Instance # @option options [String] :log_level Log level # @option options [Logger] :logger Logger instance # @option options [Hash] :sticky Sticky features - # @option options [Array] :hooks Array of hooks + # @option options [Array] :modules Array of modules + # @option options [Proc] :on_diagnostic Diagnostic handler def initialize(options = {}) # from options @context = options[:context] || {} @logger = options[:logger] || Featurevisor.create_logger(level: options[:log_level] || "info") - @hooks_manager = Featurevisor::Hooks::HooksManager.new( - hooks: (options[:hooks] || []).map { |hook_data| Featurevisor::Hooks::Hook.new(hook_data) }, - logger: @logger - ) + @on_diagnostic = options[:on_diagnostic] || options[:onDiagnostic] @emitter = Featurevisor::Emitter.new @sticky = options[:sticky] || {} + @closed = false + @module_diagnostic_subscriptions = [] # datafile @datafile_reader = Featurevisor::DatafileReader.new( @@ -40,14 +41,22 @@ def initialize(options = {}) logger: @logger ) + @modules_manager = Featurevisor::Modules::ModulesManager.new( + modules: options[:modules] || [], + report_diagnostic: method(:report_diagnostic), + module_api_factory: method(:create_module_api), + clear_module_diagnostic_subscriptions: method(:clear_module_diagnostic_subscriptions) + ) + if options[:datafile] - @datafile_reader = Featurevisor::DatafileReader.new( - datafile: options[:datafile].is_a?(String) ? JSON.parse(options[:datafile], symbolize_names: true) : options[:datafile], - logger: @logger - ) + set_datafile(options[:datafile], true) end - @logger.info("Featurevisor SDK initialized") + report_diagnostic( + level: "info", + code: "sdk_initialized", + message: "Featurevisor SDK initialized" + ) end # Set the log level @@ -58,20 +67,36 @@ def set_log_level(level) # Set the datafile # @param datafile [Hash, String] Datafile content or JSON string - def set_datafile(datafile) + # @param replace [Boolean] Whether to replace instead of merge + def set_datafile(datafile, replace = false) + return if @closed + begin + parsed_datafile = datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile + next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile_reader.get_datafile, parsed_datafile) new_datafile_reader = Featurevisor::DatafileReader.new( - datafile: datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile, + datafile: next_datafile, logger: @logger ) - details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader) + details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader, replace) @datafile_reader = new_datafile_reader @logger.info("datafile set", details) @emitter.trigger("datafile_set", details) + report_diagnostic( + level: "info", + code: "datafile_set", + message: "datafile set", + details: details + ) rescue => e - @logger.error("could not parse datafile", { error: e }) + report_diagnostic( + level: "error", + code: "invalid_datafile", + message: "could not parse datafile", + original_error: e + ) end end @@ -109,11 +134,15 @@ def get_feature(feature_key) @datafile_reader.get_feature(feature_key) end - # Add a hook - # @param hook [Hook] Hook to add - # @return [Proc, nil] Remove function or nil if hook already exists - def add_hook(hook) - @hooks_manager.add(hook) + # Add a module + # @param mod [Hash, FeaturevisorModule] Module to add + # @return [Proc, nil] Remove function or nil if module already exists + def add_module(mod) + @modules_manager.add(mod) + end + + def remove_module(name_or_module) + @modules_manager.remove(name_or_module) end # Subscribe to an event @@ -126,6 +155,9 @@ def on(event_name, callback) # Close the instance def close + @closed = true + @modules_manager.close_all + @module_diagnostic_subscriptions = [] @emitter.clear_all end @@ -182,7 +214,7 @@ def spawn(context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_flag(feature_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "flag", feature_key: feature_key @@ -211,7 +243,7 @@ def is_enabled(feature_key, context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_variation(feature_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "variation", feature_key: feature_key @@ -248,7 +280,7 @@ def get_variation(feature_key, context = {}, options = {}) # @param options [Hash] Override options # @return [Hash] Evaluation result def evaluate_variable(feature_key, variable_key, context = {}, options = {}) - Featurevisor::Evaluate.evaluate_with_hooks( + Featurevisor::Evaluate.evaluate_with_modules( get_evaluation_dependencies(context, options).merge( type: "variable", feature_key: feature_key, @@ -417,7 +449,7 @@ def get_evaluation_dependencies(context, options = {}) { context: get_context(context), logger: @logger, - hooks_manager: @hooks_manager, + modules_manager: @modules_manager, datafile_reader: @datafile_reader, sticky: options[:sticky] ? { **(@sticky || {}), **options[:sticky] } : @sticky, default_variation_value: options[:default_variation_value], @@ -452,6 +484,80 @@ def get_value_by_type(value, type) rescue nil end + + def merge_datafiles(previous, incoming) + previous ||= EMPTY_DATAFILE + incoming ||= EMPTY_DATAFILE + + { + schemaVersion: incoming[:schemaVersion], + revision: incoming[:revision], + featurevisorVersion: incoming[:featurevisorVersion], + segments: { + **(previous[:segments] || {}), + **(incoming[:segments] || {}) + }, + features: { + **(previous[:features] || {}), + **(incoming[:features] || {}) + } + }.compact + end + + def create_module_api(mod) + instance = self + { + get_revision: -> { instance.get_revision }, + on_diagnostic: lambda do |handler, options = {}| + subscription = { + id: SecureRandom.uuid, + module_id: mod.id, + handler: handler, + level: options[:level] || options[:log_level] || "info" + } + @module_diagnostic_subscriptions << subscription + -> { @module_diagnostic_subscriptions.reject! { |item| item[:id] == subscription[:id] } } + end, + report_diagnostic: ->(diagnostic) { report_diagnostic(diagnostic, mod) } + } + end + + def clear_module_diagnostic_subscriptions(mod) + @module_diagnostic_subscriptions.reject! { |item| item[:module_id] == mod.id } + end + + def report_diagnostic(diagnostic, source_module = nil) + diagnostic = (diagnostic || {}).dup + diagnostic[:level] ||= "info" + diagnostic[:module] ||= source_module.name if source_module && source_module.name + + @module_diagnostic_subscriptions.dup.each do |subscription| + next if source_module && subscription[:module_id] == source_module.id + next unless should_report_diagnostic?(diagnostic[:level], subscription[:level]) + + subscription[:handler].call(diagnostic) + end + + if @on_diagnostic + @on_diagnostic.call(diagnostic) if should_report_diagnostic?(diagnostic[:level], @logger.level) + else + @logger.log(diagnostic[:level], diagnostic[:message], diagnostic.reject { |key, _| key == :message || key == :level }) + end + + if %w[error fatal].include?(diagnostic[:level]) + @emitter.trigger("error", diagnostic) + end + end + + def should_report_diagnostic?(diagnostic_level, subscriber_level) + levels = Featurevisor::LOG_LEVELS + diagnostic_index = levels.index(diagnostic_level || "info") + subscriber_index = levels.index(subscriber_level || "info") + + return false if diagnostic_index.nil? || subscriber_index.nil? + + subscriber_index >= diagnostic_index + end end # Create a new Featurevisor instance diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb new file mode 100644 index 0000000..03407a1 --- /dev/null +++ b/lib/featurevisor/modules.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "securerandom" + +module Featurevisor + # Modules extend evaluation behavior and SDK lifecycle. + module Modules + class FeaturevisorModule + attr_reader :id, :name + + def initialize(options = {}) + @id = SecureRandom.uuid + @name = options[:name] + @setup = options[:setup] + @before = options[:before] + @bucket_key = options[:bucket_key] + @bucket_value = options[:bucket_value] + @after = options[:after] + @close = options[:close] + end + + def call_setup(api) + @setup.call(api) if @setup + end + + def call_before(options) + return options unless @before + + @before.call(options) + end + + def call_bucket_key(options) + return options[:bucket_key] unless @bucket_key + + @bucket_key.call(options) + end + + def call_bucket_value(options) + return options[:bucket_value] unless @bucket_value + + @bucket_value.call(options) + end + + def call_after(evaluation, options) + return evaluation unless @after + + @after.call(evaluation, options) + end + + def call_close + @close.call if @close + end + end + + class ModulesManager + attr_reader :modules + + def initialize(options = {}) + @modules = [] + @report_diagnostic = options[:report_diagnostic] + @module_api_factory = options[:module_api_factory] + @clear_module_diagnostic_subscriptions = options[:clear_module_diagnostic_subscriptions] + + (options[:modules] || []).each do |mod| + add(mod) + end + end + + def add(mod) + mod = FeaturevisorModule.new(mod) if mod.is_a?(Hash) + return nil unless mod + + if mod.name && !mod.name.to_s.empty? && @modules.any? { |existing| existing.name == mod.name } + report( + { + level: "error", + code: "duplicate_module", + message: "Duplicate module name", + module_name: mod.name + }, + mod + ) + return nil + end + + mod.call_setup(@module_api_factory.call(mod)) if @module_api_factory + @modules << mod + + -> { remove(mod) } + end + + def remove(name_or_module) + removed = [] + @modules = @modules.reject do |mod| + matches = name_or_module.equal?(mod) || mod.name == name_or_module + removed << mod if matches + matches + end + + removed.each do |mod| + mod.call_close + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + end + end + + def get_all + @modules + end + + def run_before_modules(options) + @modules.reduce(options) do |result, mod| + mod.call_before(result) + end + end + + def run_bucket_key_modules(options) + bucket_key = options[:bucket_key] + @modules.each do |mod| + bucket_key = mod.call_bucket_key(options.merge(bucket_key: bucket_key)) + end + bucket_key + end + + def run_bucket_value_modules(options) + bucket_value = options[:bucket_value] + @modules.each do |mod| + bucket_value = mod.call_bucket_value(options.merge(bucket_value: bucket_value)) + end + bucket_value + end + + def run_after_modules(evaluation, options) + @modules.reduce(evaluation) do |result, mod| + mod.call_after(result, options) + end + end + + def close_all + @modules.each do |mod| + mod.call_close + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + end + @modules = [] + end + + private + + def report(diagnostic, mod) + @report_diagnostic.call(diagnostic, mod) if @report_diagnostic + end + end + end +end diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 643de6e..ed5d38e 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -48,12 +48,20 @@ expect(options.with_tags).to be true end - it "parses camelCase aliases for scope/tag flags" do + it "parses legacy compatibility flags" do options = FeaturevisorCLI::Parser.parse(["test", "--withScopes", "--withTags"]) expect(options.with_scopes).to be true expect(options.with_tags).to be true end + it "accepts legacy schema version aliases" do + kebab_options = FeaturevisorCLI::Parser.parse(["test", "--schema-version=2"]) + camel_options = FeaturevisorCLI::Parser.parse(["test", "--schemaVersion=2"]) + + expect(kebab_options.schema_version).to eq("2") + expect(camel_options.schema_version).to eq("2") + end + it "sets default values" do options = FeaturevisorCLI::Parser.parse(["test"]) expect(options.n).to eq(1000) diff --git a/spec/emitter_spec.rb b/spec/emitter_spec.rb index 4fad618..0558c9c 100644 --- a/spec/emitter_spec.rb +++ b/spec/emitter_spec.rb @@ -172,7 +172,7 @@ describe "constants" do it "should have correct event names" do - expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_set]) + expect(Featurevisor::EVENT_NAMES).to eq(%w[datafile_set context_set sticky_set error]) end end diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index 53e373e..ede4ac8 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -37,7 +37,7 @@ end end - describe "evaluate_with_hooks" do + describe "evaluate_with_modules" do let(:logger) { Featurevisor.create_logger(level: "warn") } let(:datafile_reader) do Featurevisor::DatafileReader.new( @@ -50,10 +50,10 @@ logger: logger ) end - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } + let(:modules_manager) { Featurevisor::Modules::ModulesManager.new(logger: logger) } it "should be a method" do - expect(Featurevisor::Evaluate).to respond_to(:evaluate_with_hooks) + expect(Featurevisor::Evaluate).to respond_to(:evaluate_with_modules) end it "should handle errors gracefully" do @@ -62,14 +62,14 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } # Mock datafile_reader to raise an error allow(datafile_reader).to receive(:get_feature).and_raise(StandardError.new("Test error")) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::ERROR) expect(result[:error]).to be_a(StandardError) @@ -77,18 +77,18 @@ end it "should apply default variation value when specified" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(hook_applied: true) } + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-module", + after: ->(eval, opts) { eval.merge(module_applied: true) } ) - hooks_manager.add(hook) + modules_manager.add(mod) options = { type: "variation", feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, default_variation_value: "default" } @@ -96,18 +96,18 @@ # Mock datafile_reader to return no feature allow(datafile_reader).to receive(:get_feature).and_return(nil) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::FEATURE_NOT_FOUND) - expect(result[:hook_applied]).to be true + expect(result[:module_applied]).to be true end it "should apply default variable value when specified" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(hook_applied: true) } + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-module", + after: ->(eval, opts) { eval.merge(module_applied: true) } ) - hooks_manager.add(hook) + modules_manager.add(mod) options = { type: "variable", @@ -115,7 +115,7 @@ variable_key: "test-var", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, default_variable_value: "default" } @@ -123,10 +123,10 @@ # Mock datafile_reader to return no feature allow(datafile_reader).to receive(:get_feature).and_return(nil) - result = Featurevisor::Evaluate.evaluate_with_hooks(options) + result = Featurevisor::Evaluate.evaluate_with_modules(options) expect(result[:reason]).to eq(Featurevisor::EvaluationReason::FEATURE_NOT_FOUND) - expect(result[:hook_applied]).to be true + expect(result[:module_applied]).to be true end end @@ -143,7 +143,7 @@ logger: logger ) end - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } + let(:modules_manager) { Featurevisor::Modules::ModulesManager.new(logger: logger) } it "should be a method" do expect(Featurevisor::Evaluate).to respond_to(:evaluate) @@ -155,7 +155,7 @@ feature_key: "non-existent-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -177,7 +177,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -201,7 +201,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -227,7 +227,7 @@ variable_key: "test-var", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader, sticky: sticky } @@ -260,7 +260,7 @@ feature_key: feature, context: { userId: "123" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -298,7 +298,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -355,7 +355,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -398,7 +398,7 @@ feature_key: feature, context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -411,7 +411,7 @@ feature_key: "test-feature", context: {}, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader } @@ -471,7 +471,7 @@ variable_key: :test_var, context: { country: "nl" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) @@ -533,7 +533,7 @@ variable_key: :test_var, context: { country: "nl" }, logger: logger, - hooks_manager: hooks_manager, + modules_manager: modules_manager, datafile_reader: datafile_reader ) diff --git a/spec/events_spec.rb b/spec/events_spec.rb index 06987b3..a6bdf0e 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -77,7 +77,8 @@ def build_reader(revision:, features:) revision: "2", previous_revision: "1", revision_changed: true, - features: %i[feature1 feature2] + features: %i[feature1 feature2], + replaced: false }) end @@ -104,7 +105,8 @@ def build_reader(revision:, features:) revision: "2", previous_revision: "1", revision_changed: true, - features: %i[feature2 feature3] + features: %i[feature2 feature3], + replaced: false }) end @@ -129,7 +131,8 @@ def build_reader(revision:, features:) revision: "2", previous_revision: "1", revision_changed: true, - features: %i[feature1 feature2] + features: %i[feature1 feature2], + replaced: false }) end end diff --git a/spec/hooks_spec.rb b/spec/hooks_spec.rb deleted file mode 100644 index ef2af95..0000000 --- a/spec/hooks_spec.rb +++ /dev/null @@ -1,236 +0,0 @@ -require "featurevisor" - -RSpec.describe Featurevisor::Hooks do - describe "Hook" do - let(:logger) { Featurevisor.create_logger(level: "warn") } - - it "should be a class" do - expect(Featurevisor::Hooks::Hook).to be_a(Class) - end - - it "should initialize with options" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - before: ->(opts) { opts.merge(test: true) }, - after: ->(eval, opts) { eval.merge(test: true) } - ) - - expect(hook.name).to eq("test-hook") - expect(hook).to respond_to(:call_before) - expect(hook).to respond_to(:call_after) - end - - it "should call before hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - before: ->(opts) { opts.merge(test: true) } - ) - - result = hook.call_before({ original: true }) - expect(result[:test]).to be true - expect(result[:original]).to be true - end - - it "should return original options when before hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_before({ original: true }) - expect(result[:original]).to be true - expect(result[:test]).to be_nil - end - - it "should call bucket key hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } - ) - - result = hook.call_bucket_key({ bucket_key: "original" }) - expect(result).to eq("modified-original") - end - - it "should return original bucket key when bucket key hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_bucket_key({ bucket_key: "original" }) - expect(result).to eq("original") - end - - it "should call bucket value hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_value: ->(opts) { opts[:bucket_value] * 2 } - ) - - result = hook.call_bucket_value({ bucket_value: 50 }) - expect(result).to eq(100) - end - - it "should return original bucket value when bucket value hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_bucket_value({ bucket_value: 50 }) - expect(result).to eq(50) - end - - it "should call after hook when defined" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - after: ->(eval, opts) { eval.merge(test: true) } - ) - - result = hook.call_after({ original: true }, { options: true }) - expect(result[:test]).to be true - expect(result[:original]).to be true - end - - it "should return original evaluation when after hook is not defined" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - - result = hook.call_after({ original: true }, { options: true }) - expect(result[:original]).to be true - expect(result[:test]).to be_nil - end - end - - describe "HooksManager" do - let(:logger) { Featurevisor.create_logger(level: "warn") } - let(:hooks_manager) { Featurevisor::Hooks::HooksManager.new(logger: logger) } - - it "should be a class" do - expect(Featurevisor::Hooks::HooksManager).to be_a(Class) - end - - it "should initialize with options" do - expect(hooks_manager.logger).to eq(logger) - expect(hooks_manager.hooks).to eq([]) - end - - it "should add hooks" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - remove_fn = hooks_manager.add(hook) - - expect(hooks_manager.hooks).to include(hook) - expect(remove_fn).to be_a(Proc) - end - - it "should not add duplicate hooks" do - hook1 = Featurevisor::Hooks::Hook.new(name: "test-hook") - hook2 = Featurevisor::Hooks::Hook.new(name: "test-hook") - - hooks_manager.add(hook1) - result = hooks_manager.add(hook2) - - expect(hooks_manager.hooks).to eq([hook1]) - expect(result).to be_nil - end - - it "should remove hooks by name" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - hooks_manager.add(hook) - - hooks_manager.remove("test-hook") - expect(hooks_manager.hooks).to be_empty - end - - it "should get all hooks" do - hook1 = Featurevisor::Hooks::Hook.new(name: "hook1") - hook2 = Featurevisor::Hooks::Hook.new(name: "hook2") - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - expect(hooks_manager.get_all).to eq([hook1, hook2]) - end - - it "should run before hooks" do - hook1 = Featurevisor::Hooks::Hook.new( - name: "hook1", - before: ->(opts) { opts.merge(hook1: true) } - ) - hook2 = Featurevisor::Hooks::Hook.new( - name: "hook2", - before: ->(opts) { opts.merge(hook2: true) } - ) - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - result = hooks_manager.run_before_hooks({ original: true }) - expect(result[:hook1]).to be true - expect(result[:hook2]).to be true - expect(result[:original]).to be true - end - - it "should run bucket key hooks" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } - ) - - hooks_manager.add(hook) - - result = hooks_manager.run_bucket_key_hooks({ - feature_key: "test", - context: {}, - bucket_by: "userId", - bucket_key: "original" - }) - - expect(result).to eq("modified-original") - end - - it "should run bucket value hooks" do - hook = Featurevisor::Hooks::Hook.new( - name: "test-hook", - bucket_value: ->(opts) { opts[:bucket_value] * 2 } - ) - - hooks_manager.add(hook) - - result = hooks_manager.run_bucket_value_hooks({ - feature_key: "test", - bucket_key: "test.123", - context: {}, - bucket_value: 50 - }) - - expect(result).to eq(100) - end - - it "should run after hooks" do - hook1 = Featurevisor::Hooks::Hook.new( - name: "hook1", - after: ->(eval, opts) { eval.merge(hook1: true) } - ) - hook2 = Featurevisor::Hooks::Hook.new( - name: "hook2", - after: ->(eval, opts) { eval.merge(hook2: true) } - ) - - hooks_manager.add(hook1) - hooks_manager.add(hook2) - - result = hooks_manager.run_after_hooks({ original: true }, { options: true }) - expect(result[:hook1]).to be true - expect(result[:hook2]).to be true - expect(result[:original]).to be true - end - - it "should initialize with existing hooks" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - manager = Featurevisor::Hooks::HooksManager.new(hooks: [hook], logger: logger) - - expect(manager.hooks).to include(hook) - end - - it "should return remove function when adding hook" do - hook = Featurevisor::Hooks::Hook.new(name: "test-hook") - remove_fn = hooks_manager.add(hook) - - expect(remove_fn).to be_a(Proc) - remove_fn.call - expect(hooks_manager.hooks).to be_empty - end - end -end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 4cea4ca..b522dc3 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -45,7 +45,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -93,7 +93,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -141,7 +141,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", bucket_key: ->(options) { @@ -175,7 +175,7 @@ expect(captured_bucket_key).to eq("456.test") end - it "should intercept context: before hook" do + it "should intercept context: before module" do intercepted = false intercepted_feature_key = "" intercepted_variable_key = "" @@ -204,7 +204,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", before: ->(options) { @@ -231,7 +231,7 @@ expect(intercepted_variable_key).to be_nil end - it "should intercept value: after hook" do + it "should intercept value: after module" do intercepted = false intercepted_feature_key = "" intercepted_variable_key = "" @@ -260,7 +260,7 @@ }, segments: {} }, - hooks: [ + modules: [ { name: "unit-test", after: ->(evaluation, options) { @@ -642,7 +642,7 @@ bucket_value = 10_000 sdk = Featurevisor.create_instance( - hooks: [ + modules: [ { name: "unit-test", bucket_value: ->(options) { @@ -1299,6 +1299,167 @@ expect(sdk.is_enabled("manualTest", { userId: "123" })).to be true end + it "should merge datafiles by default and preserve featurevisorVersion" do + sdk = Featurevisor.create_instance( + datafile: { + schemaVersion: "2", + revision: "1.0", + featurevisorVersion: "3.0.0", + segments: {}, + features: { + firstFeature: { + key: "firstFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + sdk.set_datafile( + { + schemaVersion: "2", + revision: "2.0", + featurevisorVersion: "3.1.0", + segments: {}, + features: { + secondFeature: { + key: "secondFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + expect(sdk.get_revision).to eq("2.0") + expect(sdk.datafile_reader.featurevisor_version).to eq("3.1.0") + expect(sdk.get_feature("firstFeature")).to be_a(Hash) + expect(sdk.get_feature("secondFeature")).to be_a(Hash) + end + + it "should replace datafile when replace is true" do + sdk = Featurevisor.create_instance( + datafile: { + schemaVersion: "2", + revision: "1.0", + segments: {}, + features: { + firstFeature: { + key: "firstFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + } + ) + + sdk.set_datafile( + { + schemaVersion: "2", + revision: "2.0", + segments: {}, + features: { + secondFeature: { + key: "secondFeature", + bucketBy: "userId", + traffic: [{ key: "1", segments: "*", percentage: 100_000, allocation: [] }] + } + } + }, + true + ) + + expect(sdk.get_feature("firstFeature")).to be_nil + expect(sdk.get_feature("secondFeature")).to be_a(Hash) + end + + it "should include replaced flag in datafile_set events" do + events = [] + sdk = Featurevisor.create_instance + sdk.on("datafile_set", ->(event) { events << event }) + + sdk.set_datafile({ schemaVersion: "2", revision: "1.0", segments: {}, features: {} }) + sdk.set_datafile({ schemaVersion: "2", revision: "2.0", segments: {}, features: {} }, true) + + expect(events.map { |event| event[:replaced] }).to eq([false, true]) + end + + it "should run module setup and close lifecycle callbacks" do + setup_revision = nil + closed = false + + sdk = Featurevisor.create_instance( + modules: [ + { + name: "lifecycle", + setup: ->(api) { setup_revision = api[:get_revision].call }, + close: -> { closed = true } + } + ] + ) + + expect(setup_revision).to eq("unknown") + + sdk.close + + expect(closed).to be true + end + + it "should report duplicate module diagnostics and emit error events" do + diagnostics = [] + errors = [] + sdk = Featurevisor.create_instance( + logger: Featurevisor.create_logger(level: "error"), + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, + modules: [{ name: "duplicate" }] + ) + sdk.on("error", ->(event) { errors << event }) + + result = sdk.add_module(name: "duplicate") + + expect(result).to be_nil + expect(diagnostics.last).to include( + level: "error", + code: "duplicate_module", + module_name: "duplicate" + ) + expect(errors.last).to include(code: "duplicate_module") + end + + it "should support module diagnostic subscribe and report behavior" do + module_api = nil + received_by_instance = [] + received_by_module = [] + + sdk = Featurevisor.create_instance( + on_diagnostic: ->(diagnostic) { received_by_instance << diagnostic }, + modules: [ + { + name: "listener", + setup: ->(api) { + api[:on_diagnostic].call(->(diagnostic) { received_by_module << diagnostic }) + } + }, + { + name: "reporter", + setup: ->(api) { module_api = api } + } + ] + ) + + module_api[:report_diagnostic].call(level: "warn", code: "module_warning", message: "module warning") + + expect(received_by_instance.last).to include(code: "module_warning", module: "reporter") + expect(received_by_module.last).to include(code: "module_warning", module: "reporter") + + sdk.remove_module("listener") + module_api[:report_diagnostic].call(level: "warn", code: "after_remove", message: "after remove") + + expect(received_by_instance.last).to include(code: "after_remove", module: "reporter") + expect(received_by_module.last).to include(code: "module_warning", module: "reporter") + end + describe "get_value_by_type" do let(:sdk) do Featurevisor.create_instance( diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb new file mode 100644 index 0000000..57fdffb --- /dev/null +++ b/spec/modules_spec.rb @@ -0,0 +1,290 @@ +require "featurevisor" + +RSpec.describe Featurevisor::Modules do + describe "Module" do + let(:logger) { Featurevisor.create_logger(level: "warn") } + + it "should be a class" do + expect(Featurevisor::Modules::FeaturevisorModule).to be_a(Class) + end + + it "should initialize with options" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + setup: ->(_api) {}, + before: ->(opts) { opts.merge(test: true) }, + after: ->(eval, opts) { eval.merge(test: true) }, + close: -> {} + ) + + expect(mod.name).to eq("test-mod") + expect(mod).to respond_to(:call_setup) + expect(mod).to respond_to(:call_before) + expect(mod).to respond_to(:call_after) + expect(mod).to respond_to(:call_close) + end + + it "should call setup when defined" do + received_api = nil + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + setup: ->(api) { received_api = api } + ) + + api = { get_revision: -> { "1" } } + mod.call_setup(api) + + expect(received_api).to eq(api) + end + + it "should call close when defined" do + closed = false + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + close: -> { closed = true } + ) + + mod.call_close + + expect(closed).to be true + end + + it "should call before mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + before: ->(opts) { opts.merge(test: true) } + ) + + result = mod.call_before({ original: true }) + expect(result[:test]).to be true + expect(result[:original]).to be true + end + + it "should return original options when before mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_before({ original: true }) + expect(result[:original]).to be true + expect(result[:test]).to be_nil + end + + it "should call bucket key mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } + ) + + result = mod.call_bucket_key({ bucket_key: "original" }) + expect(result).to eq("modified-original") + end + + it "should return original bucket key when bucket key mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_bucket_key({ bucket_key: "original" }) + expect(result).to eq("original") + end + + it "should call bucket value mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_value: ->(opts) { opts[:bucket_value] * 2 } + ) + + result = mod.call_bucket_value({ bucket_value: 50 }) + expect(result).to eq(100) + end + + it "should return original bucket value when bucket value mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_bucket_value({ bucket_value: 50 }) + expect(result).to eq(50) + end + + it "should call after mod when defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + after: ->(eval, opts) { eval.merge(test: true) } + ) + + result = mod.call_after({ original: true }, { options: true }) + expect(result[:test]).to be true + expect(result[:original]).to be true + end + + it "should return original evaluation when after mod is not defined" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + result = mod.call_after({ original: true }, { options: true }) + expect(result[:original]).to be true + expect(result[:test]).to be_nil + end + end + + describe "ModulesManager" do + let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:diagnostics) { [] } + let(:modules_manager) do + Featurevisor::Modules::ModulesManager.new( + logger: logger, + report_diagnostic: ->(diagnostic, _mod = nil) { diagnostics << diagnostic } + ) + end + + it "should be a class" do + expect(Featurevisor::Modules::ModulesManager).to be_a(Class) + end + + it "should initialize with options" do + expect(modules_manager.modules).to eq([]) + end + + it "should add modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + remove_fn = modules_manager.add(mod) + + expect(modules_manager.modules).to include(mod) + expect(remove_fn).to be_a(Proc) + end + + it "should not add duplicate modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + + modules_manager.add(module1) + result = modules_manager.add(module2) + + expect(modules_manager.modules).to eq([module1]) + expect(result).to be_nil + expect(diagnostics.last).to include( + level: "error", + code: "duplicate_module", + module_name: "test-mod" + ) + end + + it "should remove modules by name" do + closed = false + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod", close: -> { closed = true }) + modules_manager.add(mod) + + modules_manager.remove("test-mod") + expect(modules_manager.modules).to be_empty + expect(closed).to be true + end + + it "should get all modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "module1") + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "module2") + + modules_manager.add(module1) + modules_manager.add(module2) + + expect(modules_manager.get_all).to eq([module1, module2]) + end + + it "should run before modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module1", + before: ->(opts) { opts.merge(module1: true) } + ) + module2 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module2", + before: ->(opts) { opts.merge(module2: true) } + ) + + modules_manager.add(module1) + modules_manager.add(module2) + + result = modules_manager.run_before_modules({ original: true }) + expect(result[:module1]).to be true + expect(result[:module2]).to be true + expect(result[:original]).to be true + end + + it "should run bucket key modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_key: ->(opts) { "modified-#{opts[:bucket_key]}" } + ) + + modules_manager.add(mod) + + result = modules_manager.run_bucket_key_modules({ + feature_key: "test", + context: {}, + bucket_by: "userId", + bucket_key: "original" + }) + + expect(result).to eq("modified-original") + end + + it "should run bucket value modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "test-mod", + bucket_value: ->(opts) { opts[:bucket_value] * 2 } + ) + + modules_manager.add(mod) + + result = modules_manager.run_bucket_value_modules({ + feature_key: "test", + bucket_key: "test.123", + context: {}, + bucket_value: 50 + }) + + expect(result).to eq(100) + end + + it "should run after modules" do + module1 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module1", + after: ->(eval, opts) { eval.merge(module1: true) } + ) + module2 = Featurevisor::Modules::FeaturevisorModule.new( + name: "module2", + after: ->(eval, opts) { eval.merge(module2: true) } + ) + + modules_manager.add(module1) + modules_manager.add(module2) + + result = modules_manager.run_after_modules({ original: true }, { options: true }) + expect(result[:module1]).to be true + expect(result[:module2]).to be true + expect(result[:original]).to be true + end + + it "should initialize with existing modules" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + manager = Featurevisor::Modules::ModulesManager.new(modules: [mod], logger: logger) + + expect(manager.modules).to include(mod) + end + + it "should return remove function when adding mod" do + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + remove_fn = modules_manager.add(mod) + + expect(remove_fn).to be_a(Proc) + remove_fn.call + expect(modules_manager.modules).to be_empty + end + + it "should close all modules" do + closed = [] + module1 = Featurevisor::Modules::FeaturevisorModule.new(name: "module1", close: -> { closed << "module1" }) + module2 = Featurevisor::Modules::FeaturevisorModule.new(name: "module2", close: -> { closed << "module2" }) + + modules_manager.add(module1) + modules_manager.add(module2) + modules_manager.close_all + + expect(closed).to eq(%w[module1 module2]) + expect(modules_manager.modules).to be_empty + end + end +end diff --git a/spec/test_command_spec.rb b/spec/test_command_spec.rb index 9fb374d..2f69af0 100644 --- a/spec/test_command_spec.rb +++ b/spec/test_command_spec.rb @@ -12,52 +12,37 @@ end describe "datafile routing helpers" do - it "prefers scoped datafile over tagged and base datafile" do + it "prefers target datafile over base datafile" do command = described_class.new(options) datafiles_by_key = { "production" => { schemaVersion: "2" }, - "production-tag-web" => { schemaVersion: "2", tagged: true }, - "production-scope-browsers" => { schemaVersion: "2", scoped: true } + "production-target-checkout" => { schemaVersion: "2", target: "checkout" } } assertion = { environment: "production", - scope: "browsers", - tag: "web" + target: "checkout" } datafile = command.send(:resolve_datafile_for_assertion, assertion, datafiles_by_key) - expect(datafile[:scoped]).to be true - end - - it "returns parsed scope context by name" do - command = described_class.new(options) - config = { - scopes: [ - { name: "browsers", context: { "platform" => "web" } } - ] - } - - scope_context = command.send(:get_scope_context, config, "browsers") - expect(scope_context).to eq({ platform: "web" }) + expect(datafile[:target]).to eq("checkout") end end describe "build command generation" do - it "includes scope and environment flags when building scoped datafiles" do + it "includes target and environment flags when building target datafiles" do command = described_class.new(options) expect(command).to receive(:execute_command) - .with(include("featurevisor build", "--environment=production", "--scope=browsers", "--json")) + .with(include("featurevisor build", "--environment=production", "--target=checkout", "--json")) .and_return('{"schemaVersion":"2","revision":"1","segments":{},"features":{}}') datafile = command.send( :build_single_datafile, environment: "production", - schema_version: "2", inflate: nil, - scope: "browsers" + target: "checkout" ) expect(datafile[:schemaVersion]).to eq("2") @@ -73,7 +58,6 @@ datafile = command.send( :build_single_datafile, environment: false, - schema_version: nil, inflate: nil ) @@ -119,7 +103,7 @@ { description: "missing datafile assertion", environment: "production", - scope: "browsers" + target: "checkout" } ] } @@ -130,13 +114,13 @@ $stdout = output begin expect do - command.send(:run_tests, tests, {}, {}, "warn", { scopes: [] }) + command.send(:run_tests, tests, {}, {}, "warn", {}) end.to raise_error(SystemExit) ensure $stdout = original_stdout end - expect(output.string).to include("no datafile found for assertion scope/tag/environment combination") + expect(output.string).to include("no datafile found for assertion target/environment combination") expect(output.string).to include("Test specs: 0 passed, 1 failed") expect(output.string).to include("Assertions: 0 passed, 1 failed") end From 8baa496f452ed7729b76261a66f6f45a629151d9 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 25 Jun 2026 21:29:54 +0200 Subject: [PATCH 02/16] updates --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 8a21b52..ba80b5f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Initialize with sticky](#initialize-with-sticky) - [Set sticky afterwards](#set-sticky-afterwards) - [Setting datafile](#setting-datafile) + - [Merging by default](#merging-by-default) + - [Replacing](#replacing) + - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) - [Logging](#logging) @@ -364,18 +367,49 @@ f.set_datafile(json_string) **Important**: When calling `set_datafile()`, ensure JSON is parsed with `symbolize_names: true` if you're parsing it yourself. +### Merging by default + By default, `set_datafile(datafile)` merges the incoming datafile into the SDK's current datafile: - top-level metadata such as `schemaVersion`, `revision`, and `featurevisorVersion` comes from the incoming datafile - `segments` are merged, with incoming entries overriding existing ones - `features` are merged, with incoming entries overriding existing ones +This means you can call `set_datafile` more than once with different datafiles, and the SDK instance accumulates their features and segments together. + +### Replacing + To fully replace the stored datafile, pass `true` as the second argument: ```ruby f.set_datafile(datafile_content, true) ``` +### Loading datafiles on demand + +Because merging is the default, a single SDK instance can start with a small datafile and load more datafiles later as your application needs them, instead of downloading every feature upfront. + +This pairs well with [targets](https://featurevisor.com/docs/targets/), where each target produces a smaller datafile for a specific part of your application: + +```ruby +require "open-uri" + +f = Featurevisor.create_instance({}) + +def load_datafile(f, target) + url = "https://cdn.yoursite.com/production/featurevisor-#{target}.json" + datafile = JSON.parse(URI.open(url).read, symbolize_names: true) + + # merges into whatever was loaded before + f.set_datafile(datafile) +end + +load_datafile(f, "products") + +# later, when the user reaches checkout +load_datafile(f, "checkout") +``` + ### Updating datafile You can set the datafile as many times as you want in your application, which will result in emitting a [`datafile_set`](#datafile_set) event that you can listen and react to accordingly. From 187ba1f2c7fbd017e55318e5c694e602c99c9581 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Fri, 26 Jun 2026 19:06:37 +0200 Subject: [PATCH 03/16] updates --- lib/featurevisor/datafile_reader.rb | 16 +++------ lib/featurevisor/emitter.rb | 2 +- lib/featurevisor/modules.rb | 19 ++++++++-- spec/conditions_spec.rb | 19 ++++++++++ spec/datafile_reader_spec.rb | 21 +++++++++++ spec/emitter_spec.rb | 18 ++++++++++ spec/instance_spec.rb | 56 +++++++++++++++++++++++++++++ spec/modules_spec.rb | 5 ++- 8 files changed, 140 insertions(+), 16 deletions(-) diff --git a/lib/featurevisor/datafile_reader.rb b/lib/featurevisor/datafile_reader.rb index 5a4d2c7..4460b08 100644 --- a/lib/featurevisor/datafile_reader.rb +++ b/lib/featurevisor/datafile_reader.rb @@ -183,15 +183,11 @@ def all_conditions_are_matched(conditions, context) end if conditions.is_a?(Hash) && conditions[:not] && conditions[:not].is_a?(Array) - return conditions[:not].all? do - all_conditions_are_matched({ and: conditions[:not] }, context) == false - end + return !all_conditions_are_matched({ and: conditions[:not] }, context) end if conditions.is_a?(Hash) && conditions["not"] && conditions["not"].is_a?(Array) - return conditions["not"].all? do - all_conditions_are_matched({ "and" => conditions["not"] }, context) == false - end + return !all_conditions_are_matched({ "and" => conditions["not"] }, context) end @@ -248,15 +244,11 @@ def all_segments_are_matched(group_segments, context) end if group_segments[:not] && group_segments[:not].is_a?(Array) - return group_segments[:not].all? do - all_segments_are_matched({ and: group_segments[:not] }, context) == false - end + return !all_segments_are_matched({ and: group_segments[:not] }, context) end if group_segments["not"] && group_segments["not"].is_a?(Array) - return group_segments["not"].all? do - all_segments_are_matched({ "and" => group_segments["not"] }, context) == false - end + return !all_segments_are_matched({ "and" => group_segments["not"] }, context) end diff --git a/lib/featurevisor/emitter.rb b/lib/featurevisor/emitter.rb index 0fd56cf..f55a835 100644 --- a/lib/featurevisor/emitter.rb +++ b/lib/featurevisor/emitter.rb @@ -42,7 +42,7 @@ def trigger(event_name, details = {}) return unless listeners - listeners.each do |listener| + listeners.dup.each do |listener| begin listener.call(details) rescue => err diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb index 03407a1..b5e92c6 100644 --- a/lib/featurevisor/modules.rb +++ b/lib/featurevisor/modules.rb @@ -98,8 +98,8 @@ def remove(name_or_module) end removed.each do |mod| - mod.call_close @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + close_module(mod) end end @@ -137,14 +137,29 @@ def run_after_modules(evaluation, options) def close_all @modules.each do |mod| - mod.call_close @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + close_module(mod) end @modules = [] end private + def close_module(mod) + mod.call_close + rescue => e + report( + { + level: "error", + code: "module_close_error", + message: "Module close failed", + module_name: mod.name, + original_error: e + }, + nil + ) + end + def report(diagnostic, mod) @report_diagnostic.call(diagnostic, mod) if @report_diagnostic end diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 80eac3f..18b870a 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -428,6 +428,25 @@ ).to be false end + it "should treat empty NOT as false and nested OR under NOT as none-match" do + none_of_chrome_or_firefox = [ + { + not: [ + { + or: [ + { attribute: "browser_type", operator: "equals", value: "chrome" }, + { attribute: "browser_type", operator: "equals", value: "firefox" } + ] + } + ] + } + ] + + expect(datafile_reader.all_conditions_are_matched(none_of_chrome_or_firefox, { browser_type: "chrome" })).to be false + expect(datafile_reader.all_conditions_are_matched(none_of_chrome_or_firefox, { browser_type: "edge" })).to be true + expect(datafile_reader.all_conditions_are_matched([{ not: [] }], {})).to be false + end + it "should match with OR inside AND" do conditions = [ { diff --git a/spec/datafile_reader_spec.rb b/spec/datafile_reader_spec.rb index 2fdbf0e..461fbd0 100644 --- a/spec/datafile_reader_spec.rb +++ b/spec/datafile_reader_spec.rb @@ -416,6 +416,27 @@ expect(datafile_reader.all_segments_are_matched(group[:segments], { version: "5.5" })).to be false expect(datafile_reader.all_segments_are_matched(group[:segments], { version: 5.5 })).to be false end + + it "should treat NOT segment children as an implicit AND" do + segments = { not: ["mobileUsers", "netherlands"] } + + expect(datafile_reader.all_segments_are_matched(segments, { + country: "nl", + deviceType: "mobile" + })).to be false + expect(datafile_reader.all_segments_are_matched(segments, { + country: "nl", + deviceType: "desktop" + })).to be true + end + + it "should treat empty NOT segments as false and nested OR as none-match" do + segments = { not: [{ or: ["mobileUsers", "desktopUsers"] }] } + + expect(datafile_reader.all_segments_are_matched(segments, { deviceType: "mobile" })).to be false + expect(datafile_reader.all_segments_are_matched(segments, { deviceType: "tv" })).to be true + expect(datafile_reader.all_segments_are_matched({ not: [] }, {})).to be false + end end describe "conditions" do diff --git a/spec/emitter_spec.rb b/spec/emitter_spec.rb index 0558c9c..cff1516 100644 --- a/spec/emitter_spec.rb +++ b/spec/emitter_spec.rb @@ -81,6 +81,24 @@ expect(handled_details.length).to eq(1) expect(handled_details[0]).to eq(complex_details) end + + it "should trigger a snapshot of listeners when listeners unsubscribe during dispatch" do + calls = [] + unsubscribe_second = nil + + emitter.on("sticky_set", ->(_details) { + calls << "first" + unsubscribe_second.call + }) + unsubscribe_second = emitter.on("sticky_set", ->(_details) { + calls << "second" + }) + + emitter.trigger("sticky_set") + emitter.trigger("sticky_set") + + expect(calls).to eq(%w[first second first]) + end end describe "unsubscribe functionality" do diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index b522dc3..dab39cf 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1427,6 +1427,62 @@ expect(errors.last).to include(code: "duplicate_module") end + it "should report module close errors and keep closing remaining modules" do + diagnostics = [] + errors = [] + closed = [] + + sdk = Featurevisor.create_instance( + logger: Featurevisor.create_logger(level: "error"), + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, + modules: [ + { + name: "first", + close: -> { + closed << "first" + raise "first close failed" + } + }, + { + name: "second", + close: -> { closed << "second" } + } + ] + ) + sdk.on("error", ->(event) { errors << event }) + + sdk.close + + expect(closed).to eq(%w[first second]) + expect(diagnostics).to include( + include( + level: "error", + code: "module_close_error", + module_name: "first", + original_error: be_a(RuntimeError) + ) + ) + expect(errors).to include(include(code: "module_close_error", module_name: "first")) + end + + it "should report module close errors from unsubscribe once" do + diagnostics = [] + + sdk = Featurevisor.create_instance( + logger: Featurevisor.create_logger(level: "error"), + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + + unsubscribe = sdk.add_module( + name: "dynamic", + close: -> { raise "dynamic close failed" } + ) + unsubscribe.call + unsubscribe.call + + expect(diagnostics.count { |diagnostic| diagnostic[:code] == "module_close_error" && diagnostic[:module_name] == "dynamic" }).to eq(1) + end + it "should support module diagnostic subscribe and report behavior" do module_api = nil received_by_instance = [] diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb index 57fdffb..7d148be 100644 --- a/spec/modules_spec.rb +++ b/spec/modules_spec.rb @@ -266,12 +266,15 @@ end it "should return remove function when adding mod" do - mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod") + closed = [] + mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod", close: -> { closed << "test-mod" }) remove_fn = modules_manager.add(mod) expect(remove_fn).to be_a(Proc) remove_fn.call + remove_fn.call expect(modules_manager.modules).to be_empty + expect(closed).to eq(["test-mod"]) end it "should close all modules" do From 9b55c55f49c3650c277aa324044aeb099e36ecd7 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:07:21 +0200 Subject: [PATCH 04/16] updates --- lib/featurevisor/instance.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 66190f0..431f5eb 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -94,7 +94,7 @@ def set_datafile(datafile, replace = false) report_diagnostic( level: "error", code: "invalid_datafile", - message: "could not parse datafile", + message: "Could not parse datafile", original_error: e ) end From f9e1c21136fba1a94dc056bce3dfe56a767a59de Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:27:59 +0200 Subject: [PATCH 05/16] updates --- lib/featurevisor.rb | 2 ++ lib/featurevisor/instance.rb | 4 ++-- spec/conditions_spec.rb | 2 +- spec/datafile_reader_spec.rb | 16 ++++++++-------- spec/evaluate_spec.rb | 4 ++-- spec/events_spec.rb | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/featurevisor.rb b/lib/featurevisor.rb index f591027..8050ded 100644 --- a/lib/featurevisor.rb +++ b/lib/featurevisor.rb @@ -14,4 +14,6 @@ module Featurevisor class Error < StandardError; end + + private_constant :DatafileReader end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 431f5eb..909e629 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -36,7 +36,7 @@ def initialize(options = {}) @module_diagnostic_subscriptions = [] # datafile - @datafile_reader = Featurevisor::DatafileReader.new( + @datafile_reader = DatafileReader.new( datafile: EMPTY_DATAFILE, logger: @logger ) @@ -74,7 +74,7 @@ def set_datafile(datafile, replace = false) begin parsed_datafile = datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile_reader.get_datafile, parsed_datafile) - new_datafile_reader = Featurevisor::DatafileReader.new( + new_datafile_reader = DatafileReader.new( datafile: next_datafile, logger: @logger ) diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 18b870a..6666bb6 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -3,7 +3,7 @@ RSpec.describe Featurevisor::Conditions do let(:logger) { Featurevisor.create_logger } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", diff --git a/spec/datafile_reader_spec.rb b/spec/datafile_reader_spec.rb index 461fbd0..a1d7d61 100644 --- a/spec/datafile_reader_spec.rb +++ b/spec/datafile_reader_spec.rb @@ -1,18 +1,18 @@ require "featurevisor" -RSpec.describe Featurevisor::DatafileReader do +RSpec.describe Featurevisor.const_get(:DatafileReader) do let(:logger) { Featurevisor.create_logger } describe "basic functionality" do it "should be a class" do - expect(Featurevisor::DatafileReader).to be_a(Class) + expect(Featurevisor.const_get(:DatafileReader)).to be_a(Class) end it "should create an instance with options" do datafile = { schemaVersion: "2", revision: "1", segments: {}, features: {} } - reader = Featurevisor::DatafileReader.new(datafile: datafile, logger: logger) + reader = Featurevisor.const_get(:DatafileReader).new(datafile: datafile, logger: logger) - expect(reader).to be_instance_of(Featurevisor::DatafileReader) + expect(reader).to be_instance_of(Featurevisor.const_get(:DatafileReader)) end end @@ -72,7 +72,7 @@ } end - let(:reader) { Featurevisor::DatafileReader.new(datafile: datafile_json, logger: logger) } + let(:reader) { Featurevisor.const_get(:DatafileReader).new(datafile: datafile_json, logger: logger) } it "should return requested entities" do expect(reader.get_revision).to eq("1") @@ -255,7 +255,7 @@ } end - let(:datafile_reader) { Featurevisor::DatafileReader.new(datafile: datafile_content, logger: logger) } + let(:datafile_reader) { Featurevisor.const_get(:DatafileReader).new(datafile: datafile_content, logger: logger) } it "should match everyone" do group = groups.find { |g| g[:key] == "*" } @@ -441,7 +441,7 @@ describe "conditions" do let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -670,7 +670,7 @@ describe "utility methods" do let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index ede4ac8..7e3e1bb 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -40,7 +40,7 @@ describe "evaluate_with_modules" do let(:logger) { Featurevisor.create_logger(level: "warn") } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", @@ -133,7 +133,7 @@ describe "evaluate" do let(:logger) { Featurevisor.create_logger(level: "warn") } let(:datafile_reader) do - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2.0", revision: "1", diff --git a/spec/events_spec.rb b/spec/events_spec.rb index a6bdf0e..09baf6e 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -50,7 +50,7 @@ describe ".get_params_for_datafile_set_event" do def build_reader(revision:, features:) - Featurevisor::DatafileReader.new( + Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "1.0.0", revision: revision, From 7a124a1b6bdf82c84e93f726adcd0fd36f792dc7 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 27 Jun 2026 23:39:00 +0200 Subject: [PATCH 06/16] updates --- README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ba80b5f..5d556a6 100644 --- a/README.md +++ b/README.md @@ -508,6 +508,24 @@ f = Featurevisor.create_instance( Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. +## Diagnostics + +Diagnostics provide structured SDK and module events for observability. + +```ruby +f = Featurevisor.create_instance( + on_diagnostic: ->(diagnostic) { + level = diagnostic[:level] + code = diagnostic[:code] + message = diagnostic[:message] + + puts "[#{level}] #{code}: #{message}" + } +) +``` + +If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -610,24 +628,6 @@ And optionally these properties depending on whether you are evaluating a featur - `variable_value`: the variable value - `variable_schema`: the variable schema -## Diagnostics - -Diagnostics provide structured SDK and module events for observability. - -```ruby -f = Featurevisor.create_instance( - on_diagnostic: ->(diagnostic) { - level = diagnostic[:level] - code = diagnostic[:code] - message = diagnostic[:message] - - puts "[#{level}] #{code}: #{message}" - } -) -``` - -If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. - ## Modules Modules allow you to intercept the evaluation process and customize SDK behavior. From dfe0e95ace4bc93a007002054bd45288b06d95f3 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Tue, 7 Jul 2026 23:17:38 +0200 Subject: [PATCH 07/16] benchmark --- bin/commands/benchmark.rb | 68 ++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 4e435a3..8f9a1f1 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -73,7 +73,9 @@ def run value_output = format_value(output[:value]) puts "Evaluated value : #{value_output}" puts "Total duration : #{pretty_duration(output[:duration])}" - puts "Average duration: #{pretty_duration(output[:duration] / @options.n)}" + puts "Minimum duration: #{format_duration_ms(output[:min_duration])}" + puts "Average duration: #{format_duration_ms(output[:average_duration])}" + puts "Maximum duration: #{format_duration_ms(output[:max_duration])}" end private @@ -154,61 +156,53 @@ def get_logger_level end end - def benchmark_feature_flag(instance, feature_key, context, n) - start_time = Time.now - - # Get the actual feature value from the SDK - value = instance.is_enabled(feature_key, context) + def benchmark_evaluation(n) + value = nil + total_duration_ns = 0 + min_duration_ns = nil + max_duration_ns = 0 - # Benchmark the evaluation n.times do - instance.is_enabled(feature_key, context) + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) + value = yield + duration_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - start_time + + total_duration_ns += duration_ns + min_duration_ns = duration_ns if min_duration_ns.nil? || duration_ns < min_duration_ns + max_duration_ns = duration_ns if duration_ns > max_duration_ns end - duration = Time.now - start_time + duration = total_duration_ns / 1_000_000_000.0 { value: value, - duration: duration + duration: duration, + min_duration: (min_duration_ns || 0) / 1_000_000_000.0, + average_duration: duration / n, + max_duration: max_duration_ns / 1_000_000_000.0 } end - def benchmark_feature_variation(instance, feature_key, context, n) - start_time = Time.now + def format_duration_ms(duration) + format("%.6fms", duration * 1000) + end - # Get the actual feature variation from the SDK - value = instance.get_variation(feature_key, context) + def benchmark_feature_flag(instance, feature_key, context, n) + benchmark_evaluation(n) do + instance.is_enabled(feature_key, context) + end + end - # Benchmark the evaluation - n.times do + def benchmark_feature_variation(instance, feature_key, context, n) + benchmark_evaluation(n) do instance.get_variation(feature_key, context) end - - duration = Time.now - start_time - - { - value: value, - duration: duration - } end def benchmark_feature_variable(instance, feature_key, variable_key, context, n) - start_time = Time.now - - # Get the actual variable value from the SDK - value = instance.get_variable(feature_key, variable_key, context) - - # Benchmark the evaluation - n.times do + benchmark_evaluation(n) do instance.get_variable(feature_key, variable_key, context) end - - duration = Time.now - start_time - - { - value: value, - duration: duration - } end def format_value(value) From 084fa1afc9d9616a5f3921c04f9edb446c72ebae Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 21:52:34 +0200 Subject: [PATCH 08/16] updates --- Gemfile.lock | 2 ++ featurevisor.gemspec | 1 + lib/featurevisor/instance.rb | 23 ++++++++++++++++------- spec/instance_spec.rb | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 75f80e6..2f9ac65 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,10 +2,12 @@ PATH remote: . specs: featurevisor (0.3.0) + benchmark GEM remote: https://rubygems.org/ specs: + benchmark (0.4.0) diff-lcs (1.6.2) rake (13.3.0) rspec (3.13.1) diff --git a/featurevisor.gemspec b/featurevisor.gemspec index 6e201ff..7a42ea8 100644 --- a/featurevisor.gemspec +++ b/featurevisor.gemspec @@ -15,6 +15,7 @@ Gem::Specification.new do |spec| spec.executables = ["featurevisor"] spec.require_paths = ["lib"] + spec.add_dependency "benchmark", ">= 0" spec.add_development_dependency "rspec", "~> 3.12" spec.add_development_dependency "rake", "~> 13.0" end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 909e629..d9f749a 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -82,12 +82,11 @@ def set_datafile(datafile, replace = false) details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader, replace) @datafile_reader = new_datafile_reader - @logger.info("datafile set", details) @emitter.trigger("datafile_set", details) report_diagnostic( level: "info", code: "datafile_set", - message: "datafile set", + message: "Datafile set", details: details ) rescue => e @@ -117,7 +116,12 @@ def set_sticky(sticky, replace = false) params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace) - @logger.info("sticky features set", params) + report_diagnostic( + level: "info", + code: "sticky_set", + message: "Sticky features set", + details: params + ) @emitter.trigger("sticky_set", params) end @@ -176,10 +180,15 @@ def set_context(context, replace = false) replaced: replace }) - @logger.debug(replace ? "context replaced" : "context updated", { - context: @context, - replaced: replace - }) + report_diagnostic( + level: "debug", + code: "context_set", + message: replace ? "Context replaced" : "Context updated", + details: { + context: @context, + replaced: replace + } + ) end # Get context diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index dab39cf..01ef1ea 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -18,6 +18,21 @@ expect(sdk.respond_to?(:get_variation)).to be true end + it "reports lifecycle mutation diagnostics" do + diagnostics = [] + sdk = Featurevisor.create_instance( + logger: Featurevisor.create_logger(level: "debug"), + on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } + ) + + sdk.set_datafile(schemaVersion: "2", revision: "1", segments: {}, features: {}) + sdk.set_sticky(test: { enabled: true }) + sdk.set_context(country: "nl") + + codes = diagnostics.map { |diagnostic| diagnostic[:code] } + expect(codes).to include("datafile_set", "sticky_set", "context_set") + end + it "should configure plain bucketBy" do captured_bucket_key = "" From 0e437ce59a8d88899645b90ef58f6bd28a1c1bd2 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Thu, 9 Jul 2026 22:59:13 +0200 Subject: [PATCH 09/16] diagnostics --- README.md | 4 +++ lib/featurevisor/child_instance.rb | 44 +++++++++++++++--------------- lib/featurevisor/instance.rb | 16 +++++++++-- spec/instance_spec.rb | 12 ++++---- 4 files changed, 47 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5d556a6..4dcce7f 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,8 @@ This is handy especially when you want to pass all evaluations from a backend ap For the lifecycle of the SDK instance in your application, you can set some features with sticky values, meaning that they will not be evaluated against the fetched [datafile](https://featurevisor.com/docs/building-datafiles/): +Sticky values belong to an SDK or child instance. Evaluation options do not accept sticky overrides; use `spawn(context, sticky: ...)` when a child needs its own sticky state. + ### Initialize with sticky ```ruby @@ -526,6 +528,8 @@ f = Featurevisor.create_instance( If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. +Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance; evaluation metadata belongs in `:details`. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. diff --git a/lib/featurevisor/child_instance.rb b/lib/featurevisor/child_instance.rb index 9567853..dfe1f6a 100644 --- a/lib/featurevisor/child_instance.rb +++ b/lib/featurevisor/child_instance.rb @@ -94,8 +94,8 @@ def is_enabled(feature_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -113,8 +113,8 @@ def get_variation(feature_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -134,8 +134,8 @@ def get_variable(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -155,8 +155,8 @@ def get_variable_boolean(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -176,8 +176,8 @@ def get_variable_string(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -197,8 +197,8 @@ def get_variable_integer(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -218,8 +218,8 @@ def get_variable_double(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -239,8 +239,8 @@ def get_variable_array(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -260,8 +260,8 @@ def get_variable_object(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -281,8 +281,8 @@ def get_variable_json(feature_key, variable_key, context = {}, options = {}) **context }, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end @@ -300,8 +300,8 @@ def get_all_evaluations(context = {}, feature_keys = [], options = {}) }, feature_keys, { - sticky: @sticky, - **options + **options, + __featurevisor_child_sticky: @sticky } ) end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index d9f749a..8838e34 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -460,7 +460,7 @@ def get_evaluation_dependencies(context, options = {}) logger: @logger, modules_manager: @modules_manager, datafile_reader: @datafile_reader, - sticky: options[:sticky] ? { **(@sticky || {}), **options[:sticky] } : @sticky, + sticky: options[:__featurevisor_child_sticky] || @sticky, default_variation_value: options[:default_variation_value], default_variable_value: options[:default_variable_value] } @@ -539,6 +539,18 @@ def report_diagnostic(diagnostic, source_module = nil) diagnostic = (diagnostic || {}).dup diagnostic[:level] ||= "info" diagnostic[:module] ||= source_module.name if source_module && source_module.name + details = (diagnostic[:details] || {}).dup + legacy_module_name = diagnostic.delete(:module_name) + legacy_original_error = diagnostic.delete(:original_error) + diagnostic[:moduleName] = legacy_module_name if !diagnostic.key?(:moduleName) && !legacy_module_name.nil? + diagnostic[:originalError] = legacy_original_error if !diagnostic.key?(:originalError) && !legacy_original_error.nil? + diagnostic.each do |key, value| + next if %i[level code message module moduleName originalError details].include?(key) + + details[key] = value + end + diagnostic.select! { |key, _| %i[level code message module moduleName originalError details].include?(key) } + diagnostic[:details] = details @module_diagnostic_subscriptions.dup.each do |subscription| next if source_module && subscription[:module_id] == source_module.id @@ -550,7 +562,7 @@ def report_diagnostic(diagnostic, source_module = nil) if @on_diagnostic @on_diagnostic.call(diagnostic) if should_report_diagnostic?(diagnostic[:level], @logger.level) else - @logger.log(diagnostic[:level], diagnostic[:message], diagnostic.reject { |key, _| key == :message || key == :level }) + @logger.log(diagnostic[:level], diagnostic[:message], diagnostic) end if %w[error fatal].include?(diagnostic[:level]) diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 01ef1ea..f000ac5 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1437,7 +1437,8 @@ expect(diagnostics.last).to include( level: "error", code: "duplicate_module", - module_name: "duplicate" + moduleName: "duplicate", + details: {} ) expect(errors.last).to include(code: "duplicate_module") end @@ -1473,11 +1474,12 @@ include( level: "error", code: "module_close_error", - module_name: "first", - original_error: be_a(RuntimeError) + moduleName: "first", + originalError: be_a(RuntimeError), + details: {} ) ) - expect(errors).to include(include(code: "module_close_error", module_name: "first")) + expect(errors).to include(include(code: "module_close_error", moduleName: "first")) end it "should report module close errors from unsubscribe once" do @@ -1495,7 +1497,7 @@ unsubscribe.call unsubscribe.call - expect(diagnostics.count { |diagnostic| diagnostic[:code] == "module_close_error" && diagnostic[:module_name] == "dynamic" }).to eq(1) + expect(diagnostics.count { |diagnostic| diagnostic[:code] == "module_close_error" && diagnostic[:moduleName] == "dynamic" }).to eq(1) end it "should support module diagnostic subscribe and report behavior" do From 51f904dc78b1244cdc1cd9e0936de6e18e3deadb Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sat, 11 Jul 2026 18:45:52 +0200 Subject: [PATCH 10/16] improvements --- README.md | 6 ++++ conformance/sdk-v3.json | 49 +++++++++++++++++++++++++++++ lib/featurevisor/datafile_reader.rb | 2 ++ lib/featurevisor/instance.rb | 21 ++++++++++--- lib/featurevisor/modules.rb | 18 ++++++++++- spec/conformance_spec.rb | 20 ++++++++++++ spec/instance_spec.rb | 26 +++++++++++---- spec/modules_spec.rb | 21 +++++++++++++ 8 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 conformance/sdk-v3.json create mode 100644 spec/conformance_spec.rb diff --git a/README.md b/README.md index 4dcce7f..2e37f0b 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ f.get_variable_object(feature_key, variable_key, context = {}) f.get_variable_json(feature_key, variable_key, context = {}) ``` +Type specific methods do not coerce values. `get_variable_integer` returns `nil` for the string `"1"`, and boolean getters return `nil` for non-boolean values. + ## Getting all evaluations You can get evaluations of all features available in the SDK instance: @@ -530,6 +532,8 @@ If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance; evaluation metadata belongs in `:details`. +Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -640,6 +644,8 @@ Modules allow you to intercept the evaluation process and customize SDK behavior A module is a simple hash with a unique recommended `name` and optional lifecycle functions: +If `setup` raises an exception, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. + ```ruby require 'featurevisor' diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json new file mode 100644 index 0000000..ceae692 --- /dev/null +++ b/conformance/sdk-v3.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "description": "Featurevisor v3 cross SDK compatibility contracts", + "bucketing": { + "minimum": 0, + "maximum": 100000, + "percentage": { + "percentage": 50000, + "enabledAt": [0, 50000], + "disabledAt": [50001, 100000] + }, + "allocations": [ + { "variation": "control", "range": [0, 50000] }, + { "variation": "treatment", "range": [50000, 100000] } + ], + "allocationExpectations": { + "0": "control", + "49999": "control", + "50000": "control", + "50001": "treatment", + "99999": "treatment", + "100000": "treatment" + } + }, + "regularExpressions": { + "pattern": "chrome", + "flags": "g", + "values": ["chrome", "chrome", "firefox", "chrome"], + "matches": [true, true, false, true] + }, + "typedVariables": [ + { "type": "integer", "value": 1, "valid": true }, + { "type": "integer", "value": 1.5, "valid": false }, + { "type": "integer", "value": "1", "valid": false }, + { "type": "double", "value": 1.5, "valid": true }, + { "type": "double", "value": "1.5", "valid": false }, + { "type": "boolean", "value": true, "valid": true }, + { "type": "boolean", "value": "true", "valid": false } + ], + "datafile": { + "schemaVersionIsInformational": true, + "schemaVersionType": "string" + }, + "diagnostics": { + "requiredFields": ["level", "code", "message", "details"], + "detailsType": "object", + "emptyDetailsJson": "{}" + } +} diff --git a/lib/featurevisor/datafile_reader.rb b/lib/featurevisor/datafile_reader.rb index 4460b08..1bede67 100644 --- a/lib/featurevisor/datafile_reader.rb +++ b/lib/featurevisor/datafile_reader.rb @@ -354,4 +354,6 @@ def parse_segments_if_stringified(segments) segments end end + + private_constant :DatafileReader end diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 8838e34..ca2edf7 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -477,11 +477,12 @@ def get_value_by_type(value, type) when "string" value.is_a?(String) ? value : nil when "integer" - value.is_a?(String) ? Integer(value, 10) : (value.is_a?(Integer) ? value : nil) + return value if value.is_a?(Integer) + value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : nil when "double" - value.is_a?(String) ? Float(value) : (value.is_a?(Numeric) ? value.to_f : nil) + value.is_a?(Numeric) && value.finite? ? value.to_f : nil when "boolean" - value == true + value == true || value == false ? value : nil when "array" value.is_a?(Array) ? value : nil when "object" @@ -556,11 +557,21 @@ def report_diagnostic(diagnostic, source_module = nil) next if source_module && subscription[:module_id] == source_module.id next unless should_report_diagnostic?(diagnostic[:level], subscription[:level]) - subscription[:handler].call(diagnostic) + begin + subscription[:handler].call(diagnostic) + rescue => e + Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}") + end end if @on_diagnostic - @on_diagnostic.call(diagnostic) if should_report_diagnostic?(diagnostic[:level], @logger.level) + if should_report_diagnostic?(diagnostic[:level], @logger.level) + begin + @on_diagnostic.call(diagnostic) + rescue => e + Kernel.warn("[Featurevisor] Diagnostic handler failed: #{e}") + end + end else @logger.log(diagnostic[:level], diagnostic[:message], diagnostic) end diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb index b5e92c6..678fec6 100644 --- a/lib/featurevisor/modules.rb +++ b/lib/featurevisor/modules.rb @@ -83,7 +83,23 @@ def add(mod) return nil end - mod.call_setup(@module_api_factory.call(mod)) if @module_api_factory + begin + mod.call_setup(@module_api_factory.call(mod)) if @module_api_factory + rescue => e + @clear_module_diagnostic_subscriptions.call(mod) if @clear_module_diagnostic_subscriptions + report( + { + level: "error", + code: "module_setup_error", + message: "Module setup failed", + module_name: mod.name, + original_error: e + }, + nil + ) + close_module(mod) + return nil + end @modules << mod -> { remove(mod) } diff --git a/spec/conformance_spec.rb b/spec/conformance_spec.rb new file mode 100644 index 0000000..5770a7c --- /dev/null +++ b/spec/conformance_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "json" +require "featurevisor" + +RSpec.describe "Featurevisor v3 conformance" do + it "uses the shared inclusive allocation contract" do + fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) + reader = Featurevisor.const_get(:DatafileReader).new( + datafile: { schemaVersion: "2", revision: "conformance", segments: {}, features: {} }, + logger: Featurevisor::Logger.new(level: "fatal") + ) + traffic = { allocation: fixture.dig(:bucketing, :allocations) } + + fixture.dig(:bucketing, :allocationExpectations).each do |bucket, expected| + allocation = reader.get_matched_allocation(traffic, bucket.to_s.to_i) + expect(allocation[:variation]).to eq(expected) + end + end +end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index f000ac5..3466518 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1533,6 +1533,18 @@ expect(received_by_module.last).to include(code: "module_warning", module: "reporter") end + it "should isolate diagnostic handler failures" do + sdk = nil + + expect { + sdk = Featurevisor.create_instance( + on_diagnostic: ->(_diagnostic) { raise "handler failed" } + ) + sdk.is_enabled("missing", {}) + sdk.close + }.not_to raise_error + end + describe "get_value_by_type" do let(:sdk) do Featurevisor.create_instance( @@ -1553,10 +1565,10 @@ expect(sdk.send(:get_value_by_type, "1", "string")).to eq("1") end - it "returns boolean coercion result for boolean type" do + it "returns booleans only for boolean type" do expect(sdk.send(:get_value_by_type, true, "boolean")).to be true expect(sdk.send(:get_value_by_type, false, "boolean")).to be false - expect(sdk.send(:get_value_by_type, "true", "boolean")).to be false + expect(sdk.send(:get_value_by_type, "true", "boolean")).to be_nil end it "returns object value for object type" do @@ -1572,12 +1584,14 @@ expect(sdk.send(:get_value_by_type, %w[1 2 3], "array")).to eq(%w[1 2 3]) end - it "parses integer for integer type" do - expect(sdk.send(:get_value_by_type, "1", "integer")).to eq(1) + it "returns integers only for integer type" do + expect(sdk.send(:get_value_by_type, 1, "integer")).to eq(1) + expect(sdk.send(:get_value_by_type, "1", "integer")).to be_nil end - it "parses double for double type" do - expect(sdk.send(:get_value_by_type, "1.1", "double")).to eq(1.1) + it "returns finite numbers only for double type" do + expect(sdk.send(:get_value_by_type, 1.1, "double")).to eq(1.1) + expect(sdk.send(:get_value_by_type, "1.1", "double")).to be_nil end it "returns nil when value is nil" do diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb index 7d148be..fc76b77 100644 --- a/spec/modules_spec.rb +++ b/spec/modules_spec.rb @@ -164,6 +164,27 @@ ) end + it "should isolate setup failures and close the failed module" do + closed = false + subscriptions_cleared = false + manager = Featurevisor::Modules::ModulesManager.new( + report_diagnostic: ->(diagnostic, _mod = nil) { diagnostics << diagnostic }, + module_api_factory: ->(_mod) { {} }, + clear_module_diagnostic_subscriptions: ->(_mod) { subscriptions_cleared = true } + ) + mod = Featurevisor::Modules::FeaturevisorModule.new( + name: "broken-setup", + setup: ->(_api) { raise "setup failed" }, + close: -> { closed = true } + ) + + expect(manager.add(mod)).to be_nil + expect(manager.modules).to be_empty + expect(closed).to be true + expect(subscriptions_cleared).to be true + expect(diagnostics.last).to include(code: "module_setup_error", module_name: "broken-setup") + end + it "should remove modules by name" do closed = false mod = Featurevisor::Modules::FeaturevisorModule.new(name: "test-mod", close: -> { closed = true }) From 16e3affd09af9e33e7bcbb5ad86610d8158e2c70 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 12:33:53 +0200 Subject: [PATCH 11/16] cli alignment --- README.md | 6 ++-- bin/cli.rb | 7 ++++- bin/commands/assess_distribution.rb | 39 +++++++++++++++++++------ bin/commands/benchmark.rb | 44 ++++++++++++++++++++--------- bin/commands/test.rb | 14 ++++++++- spec/cli_spec.rb | 5 ++++ 6 files changed, 90 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 2e37f0b..0f658cf 100644 --- a/README.md +++ b/README.md @@ -809,6 +809,8 @@ $ bundle exec featurevisor test \ The Ruby test runner builds base datafiles and Target datafiles in memory via `npx featurevisor build --json`. When an assertion contains `target`, it is evaluated against the matching Target datafile. +All three commands accept repeatable `--target=` options. `test` builds only the selected Target datafiles and runs untargeted assertions plus assertions for those targets. `benchmark` and `assess-distribution` run independently against every selected Target datafile. Without `--target`, existing project-wide behavior is preserved. Project definitions, test specs, Target discovery, and datafile generation continue to come from the Node.js CLI. + ### Test against local monorepo's example-1 ```bash @@ -819,7 +821,7 @@ $ make test-example-1 ### Benchmark -Learn more about benchmarking [here](https://featurevisor.com/docs/cmd/#benchmarking). +Learn more about benchmarking [here](https://featurevisor.com/docs/cli/#benchmarking). ```bash $ bundle exec featurevisor benchmark \ @@ -832,7 +834,7 @@ $ bundle exec featurevisor benchmark \ ### Assess distribution -Learn more about assessing distribution [here](https://featurevisor.com/docs/cmd/#assess-distribution). +Learn more about assessing distribution [here](https://featurevisor.com/docs/cli/#assess-distribution). ```bash $ bundle exec featurevisor assess-distribution \ diff --git a/bin/cli.rb b/bin/cli.rb index 8c95293..dfbf068 100644 --- a/bin/cli.rb +++ b/bin/cli.rb @@ -7,12 +7,13 @@ class Options attr_accessor :command, :assertion_pattern, :context, :environment, :feature, :key_pattern, :n, :only_failures, :quiet, :variable, :variation, :verbose, :inflate, :show_datafile, :schema_version, :project_directory_path, - :populate_uuid, :with_scopes, :with_tags + :populate_uuid, :with_scopes, :with_tags, :targets def initialize @n = 1000 @project_directory_path = Dir.pwd @populate_uuid = [] + @targets = [] end end @@ -102,6 +103,10 @@ def self.parse(args) options.populate_uuid << v end + opts.on("--target=TARGET", "Target datafile; repeat for multiple targets") do |v| + options.targets << v unless options.targets.include?(v) + end + opts.on("-h", "--help", "Show this help message") do puts opts exit diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index 83445e9..ceae4e9 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -29,13 +29,21 @@ def run exit 1 end - puts "" - puts "Assessing distribution for feature: \"#{@options.feature}\"..." - puts "" - - # Parse context if provided context = parse_context + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end + + private + + def run_for_target(context, target) + puts "\nAssess Featurevisor distribution" + puts " Feature: #{@options.feature}" + puts " Environment: #{@options.environment}" + puts " Target: #{target}" if target + puts " Iterations: #{@options.n}" + # Print context information if @options.context puts "Against context: #{@options.context}" @@ -47,7 +55,7 @@ def run puts "" # Build datafile - datafile = build_datafile(@options.environment) + datafile = build_datafile(@options.environment, target) # Create SDK instance instance = create_instance(datafile) @@ -104,7 +112,21 @@ def run end end - private + def resolve_targets + return [] if @options.targets.empty? + stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) + unless status.success? + puts stderr + exit 1 + end + available = JSON.parse(stdout).map { |item| item.is_a?(Hash) ? (item["key"] || item["name"]) : item } + unknown = @options.targets.find { |target| !available.include?(target) } + if unknown + puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." + exit 1 + end + @options.targets + end def parse_context if @options.context @@ -121,11 +143,12 @@ def parse_context end end - def build_datafile(environment) + def build_datafile(environment, target = nil) puts "Building datafile for environment: #{environment}..." # Build the command similar to Go implementation command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] + command_parts << "--target=#{target}" if target if @options.inflate command_parts << "--inflate=#{@options.inflate}" diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 8f9a1f1..8539ba0 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -27,22 +27,25 @@ def run exit 1 end - puts "" - puts "Running benchmark for feature \"#{@options.feature}\"..." - puts "" - - # Parse context if provided context = parse_context + targets = resolve_targets + (targets.empty? ? [nil] : targets).each { |target| run_for_target(context, target) } + end - puts "Building datafile containing all features for \"#{@options.environment}\"..." - datafile_build_start = Time.now + private - # Build datafile by executing the featurevisor build command - datafile = build_datafile(@options.environment) + def run_for_target(context, target) + datafile_build_start = Time.now + datafile = build_datafile(@options.environment, target) datafile_build_duration = Time.now - datafile_build_start datafile_build_duration_ms = (datafile_build_duration * 1000).round - puts "Datafile build duration: #{datafile_build_duration_ms}ms" + puts "\nBenchmark Featurevisor feature" + puts " Feature: #{@options.feature}" + puts " Environment: #{@options.environment}" + puts " Target: #{target}" if target + puts " Iterations: #{@options.n}" + puts " Build duration: #{datafile_build_duration_ms}ms" # Calculate datafile size datafile_size = datafile.to_json.bytesize @@ -78,7 +81,21 @@ def run puts "Maximum duration: #{format_duration_ms(output[:max_duration])}" end - private + def resolve_targets + return [] if @options.targets.empty? + stdout, stderr, status = Open3.capture3("npx", "featurevisor", "list", "--targets", "--json", chdir: @project_path) + unless status.success? + puts stderr + exit 1 + end + available = JSON.parse(stdout).map { |target| target.is_a?(Hash) ? (target["key"] || target["name"]) : target } + unknown = @options.targets.find { |target| !available.include?(target) } + if unknown + puts "Unknown target \"#{unknown}\". Available targets: #{available.empty? ? "none" : available.join(", ")}." + exit 1 + end + @options.targets + end def parse_context if @options.context @@ -95,14 +112,15 @@ def parse_context end end - def build_datafile(environment) + def build_datafile(environment, target = nil) puts "Building datafile for environment: #{environment}..." command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"] + command_parts << "--target=#{target}" if target # Add inflate if specified if @options.inflate && @options.inflate > 0 - command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--inflate=#{@options.inflate}", "--json"] + command_parts << "--inflate=#{@options.inflate}" end command = command_parts.join(" ") diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 2b9f371..0ccb56e 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -21,7 +21,13 @@ def run # Get project configuration config = get_config environments = get_environments(config) - targets = get_targets + available_targets = get_targets + unknown_target = @options.targets.find { |target| !available_targets.include?(target) } + if unknown_target + puts "Unknown target \"#{unknown_target}\". Available targets: #{available_targets.empty? ? "none" : available_targets.join(", ")}." + exit 1 + end + targets = @options.targets.empty? ? available_targets : @options.targets segments_by_key = get_segments # Build base and Target datafiles for all environments. @@ -259,6 +265,12 @@ def run_tests(tests, datafiles_by_key, segments_by_key, level, config) tests.each do |test| test_key = test[:key] assertions = test[:assertions] || [] + if test[:feature] && !@options.targets.empty? + assertions = assertions.select do |assertion| + assertion[:target].nil? || @options.targets.include?(assertion[:target]) + end + next if assertions.empty? + end results = "" test_has_error = false test_duration = 0.0 diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index ed5d38e..ed7bb06 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -38,6 +38,11 @@ end RSpec.describe FeaturevisorCLI::Parser do + it "parses and deduplicates repeated targets" do + options = described_class.parse(["test", "--target=web", "--target=mobile", "--target=web"]) + expect(options.targets).to eq(["web", "mobile"]) + end + describe ".parse" do it "parses basic options" do options = FeaturevisorCLI::Parser.parse(["test", "--verbose", "--n=5000", "--with-scopes", "--with-tags"]) From a1f70380d127b4209deb47559152716f60326e62 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 18:30:21 +0200 Subject: [PATCH 12/16] alignment --- README.md | 106 +++++++++------------------- bin/commands/assess_distribution.rb | 6 +- bin/commands/benchmark.rb | 6 +- bin/commands/test.rb | 18 +++-- lib/featurevisor.rb | 1 + lib/featurevisor/child_instance.rb | 2 - lib/featurevisor/instance.rb | 49 +++++++++++-- lib/featurevisor/logger.rb | 7 -- spec/bucketer_spec.rb | 2 +- spec/child_instance_spec.rb | 2 +- spec/conditions_spec.rb | 2 +- spec/conformance_spec.rb | 2 +- spec/datafile_reader_spec.rb | 2 +- spec/evaluate_spec.rb | 4 +- spec/events_spec.rb | 2 +- spec/instance_spec.rb | 87 ++++++++++++----------- spec/logger_spec.rb | 38 +++++----- spec/modules_spec.rb | 4 +- 18 files changed, 166 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 0f658cf..4d14e81 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. ## Table of contents - [Installation](#installation) +- [Public API](#public-api) - [Initialization](#initialization) - [Evaluation types](#evaluation-types) - [Context](#context) @@ -28,17 +29,15 @@ This SDK is compatible with Featurevisor v3 projects and v2 datafiles. - [Loading datafiles on demand](#loading-datafiles-on-demand) - [Updating datafile](#updating-datafile) - [Interval-based update](#interval-based-update) -- [Logging](#logging) +- [Evaluation details](#evaluation-details) +- [Diagnostics](#diagnostics) - [Levels](#levels) - - [Customizing levels](#customizing-levels) - [Handler](#handler) - [Events](#events) - [`datafile_set`](#datafile_set) - [`context_set`](#context_set) - [`sticky_set`](#sticky_set) - [`error`](#error) -- [Evaluation details](#evaluation-details) -- [Diagnostics](#diagnostics) - [Modules](#modules) - [Defining a module](#defining-a-module) - [Registering modules](#registering-modules) @@ -77,6 +76,18 @@ Or install it yourself as: $ gem install featurevisor ``` +## Public API + +The main runtime API is `Featurevisor.create_featurevisor`: + +```ruby +f = Featurevisor.create_featurevisor( + datafile: datafile_content +) +``` + +Most applications only need this factory and the returned `Featurevisor::Instance`. Public extension and observability APIs include modules, diagnostics, events, and the datafile structures accepted by the factory. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -94,7 +105,7 @@ response = Net::HTTP.get_response(URI(datafile_url)) datafile_content = JSON.parse(response.body, symbolize_names: true) # Create SDK instance -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( datafile: datafile_content ) ``` @@ -106,10 +117,10 @@ Alternatively, you can pass a JSON string directly and the SDK will parse it aut ```ruby # Option 1: Parse JSON yourself (recommended) datafile_content = JSON.parse(json_string, symbolize_names: true) -f = Featurevisor.create_instance(datafile: datafile_content) +f = Featurevisor.create_featurevisor(datafile: datafile_content) # Option 2: Pass JSON string directly (automatic parsing) -f = Featurevisor.create_instance(datafile: json_string) +f = Featurevisor.create_featurevisor(datafile: json_string) ``` ## Evaluation types @@ -147,7 +158,7 @@ You can set context at the time of initialization: ```ruby require 'featurevisor' -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( context: { deviceId: '123', country: 'nl' @@ -318,7 +329,7 @@ Sticky values belong to an SDK or child instance. Evaluation options do not acce ```ruby require 'featurevisor' -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( sticky: { myFeatureKey: { enabled: true, @@ -398,7 +409,7 @@ This pairs well with [targets](https://featurevisor.com/docs/targets/), where ea ```ruby require "open-uri" -f = Featurevisor.create_instance({}) +f = Featurevisor.create_featurevisor({}) def load_datafile(f, target) url = "https://cdn.yoursite.com/production/featurevisor-#{target}.json" @@ -452,88 +463,39 @@ end Thread.new { update_datafile(f, datafile_url) } ``` -## Logging +## Diagnostics -By default, Featurevisor SDKs will print out logs to the console for `info` level and above. +By default, Featurevisor reports diagnostics to the console for `info` level and above with a `[Featurevisor]` prefix. ### Levels -These are all the available log levels: - -- `error` -- `warn` -- `info` -- `debug` - -### Customizing levels - -If you choose `debug` level to make the logs more verbose, you can set it at the time of SDK initialization. - -Setting `debug` level will print out all logs, including `info`, `warn`, and `error` levels. - -```ruby -require 'featurevisor' - -f = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: 'debug') -) -``` +Available diagnostic levels are `fatal`, `error`, `warn`, `info`, and `debug`. -Alternatively, you can also set `log_level` directly: +Set the level during initialization or update it afterwards: ```ruby -f = Featurevisor.create_instance( - log_level: 'debug' -) -``` - -You can also set log level from SDK instance afterwards: - -```ruby -f.set_log_level('debug') +f = Featurevisor.create_featurevisor(log_level: "debug") +f.set_log_level("info") ``` ### Handler -You can also pass your own log handler, if you do not wish to print the logs to the console: +Use `on_diagnostic` to send structured diagnostics to your observability system: ```ruby -require 'featurevisor' - -f = Featurevisor.create_instance( - logger: Featurevisor.create_logger( - level: 'info', - handler: ->(level, message, details) { - # do something with the log - } - ) -) -``` - -Further log levels like `info` and `debug` will help you understand how the feature variations and variables are evaluated in the runtime against given context. - -## Diagnostics - -Diagnostics provide structured SDK and module events for observability. - -```ruby -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( + log_level: "info", on_diagnostic: ->(diagnostic) { - level = diagnostic[:level] - code = diagnostic[:code] - message = diagnostic[:message] - - puts "[#{level}] #{code}: #{message}" + puts "#{diagnostic[:level]} #{diagnostic[:code]} #{diagnostic[:message]}" } ) ``` -If `on_diagnostic` is not provided, diagnostics are sent to the SDK logger. - -Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance; evaluation metadata belongs in `:details`. +Every diagnostic has `:level`, `:code`, `:message`, and an object-shaped `:details` hash. Optional `:module`, `:moduleName`, and `:originalError` fields describe provenance. Evaluation metadata belongs in `:details`. Diagnostic handlers are isolated from SDK behavior. An exception in a handler does not stop other handlers or evaluations. + ## Events Featurevisor SDK implements a simple event emitter that allows you to listen to events that happen in the runtime. @@ -714,7 +676,7 @@ You can register modules at the time of SDK initialization: ```ruby require 'featurevisor' -f = Featurevisor.create_instance( +f = Featurevisor.create_featurevisor( modules: [my_custom_module] ) ``` diff --git a/bin/commands/assess_distribution.rb b/bin/commands/assess_distribution.rb index ceae4e9..60a4f66 100644 --- a/bin/commands/assess_distribution.rb +++ b/bin/commands/assess_distribution.rb @@ -58,7 +58,7 @@ def run_for_target(context, target) datafile = build_datafile(@options.environment, target) # Create SDK instance - instance = create_instance(datafile) + instance = create_featurevisor(datafile) # Check if feature has variations feature = instance.get_feature(@options.feature) @@ -178,9 +178,9 @@ def execute_command(command) [stdout, stderr, exit_status.exitstatus] end - def create_instance(datafile) + def create_featurevisor(datafile) # Create SDK instance - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: datafile, log_level: get_logger_level ) diff --git a/bin/commands/benchmark.rb b/bin/commands/benchmark.rb index 8539ba0..bb56a48 100644 --- a/bin/commands/benchmark.rb +++ b/bin/commands/benchmark.rb @@ -52,7 +52,7 @@ def run_for_target(context, target) puts "Datafile size: #{(datafile_size / 1024.0).round(2)} kB" # Create SDK instance with the datafile - instance = create_instance(datafile) + instance = create_featurevisor(datafile) puts "...SDK initialized" puts "" @@ -152,9 +152,9 @@ def execute_command(command) stdout end - def create_instance(datafile) + def create_featurevisor(datafile) # Create a real Featurevisor instance - instance = Featurevisor.create_instance( + instance = Featurevisor.create_featurevisor( log_level: get_logger_level ) diff --git a/bin/commands/test.rb b/bin/commands/test.rb index 0ccb56e..e6e3856 100644 --- a/bin/commands/test.rb +++ b/bin/commands/test.rb @@ -236,7 +236,7 @@ def resolve_datafile_for_assertion(assertion, datafiles_by_key) def create_tester_instance(datafile, level, assertion) sticky = parse_sticky(assertion[:sticky]) - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: datafile, sticky: sticky, log_level: level, @@ -660,7 +660,7 @@ def run_test_segment(assertion, segment, level) } # Create SDK instance for segment testing - instance = Featurevisor.create_instance( + instance = Featurevisor.create_featurevisor( datafile: datafile, log_level: level ) @@ -748,16 +748,14 @@ def evaluate_from_instance(instance, type, feature_key, context, override_option return instance.send(method_name, feature_key, variable_key, context, override_options) end - if instance.respond_to?(:parent) && instance.respond_to?(:sticky) - parent = instance.parent - combined_context = if instance.respond_to?(:context) - { **(instance.context || {}), **context } - else - context - end + if instance.is_a?(Featurevisor::ChildInstance) + parent = instance.instance_variable_get(:@parent) + child_context = instance.instance_variable_get(:@context) || {} + child_sticky = instance.instance_variable_get(:@sticky) + combined_context = { **child_context, **context } combined_options = { - sticky: instance.sticky, + __featurevisor_child_sticky: child_sticky, **override_options } diff --git a/lib/featurevisor.rb b/lib/featurevisor.rb index 8050ded..c1a7725 100644 --- a/lib/featurevisor.rb +++ b/lib/featurevisor.rb @@ -16,4 +16,5 @@ module Featurevisor class Error < StandardError; end private_constant :DatafileReader + private_constant :Logger end diff --git a/lib/featurevisor/child_instance.rb b/lib/featurevisor/child_instance.rb index dfe1f6a..87535ca 100644 --- a/lib/featurevisor/child_instance.rb +++ b/lib/featurevisor/child_instance.rb @@ -3,8 +3,6 @@ module Featurevisor # Child instance class for managing child contexts and sticky features class ChildInstance - attr_reader :parent, :context, :sticky, :emitter - # Initialize a new child instance # @param options [Hash] Child instance options # @option options [Instance] :parent Parent instance diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index ca2edf7..2897487 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -6,8 +6,6 @@ module Featurevisor # Instance class for managing feature flag evaluations class Instance - attr_reader :context, :logger, :sticky, :datafile_reader, :modules_manager, :emitter - # Empty datafile template EMPTY_DATAFILE = { schemaVersion: "2", @@ -21,14 +19,16 @@ class Instance # @option options [Hash, String] :datafile Datafile content or JSON string # @option options [Hash] :context Initial context # @option options [String] :log_level Log level - # @option options [Logger] :logger Logger instance # @option options [Hash] :sticky Sticky features # @option options [Array] :modules Array of modules # @option options [Proc] :on_diagnostic Diagnostic handler def initialize(options = {}) # from options @context = options[:context] || {} - @logger = options[:logger] || Featurevisor.create_logger(level: options[:log_level] || "info") + @logger = Logger.new( + level: options[:log_level] || "info", + handler: method(:handle_internal_log) + ) @on_diagnostic = options[:on_diagnostic] || options[:onDiagnostic] @emitter = Featurevisor::Emitter.new @sticky = options[:sticky] || {} @@ -65,6 +65,19 @@ def set_log_level(level) @logger.set_level(level) end + def handle_internal_log(level, message, details = nil) + details = (details || {}).dup + code = details[:reason] || details["reason"] || message + code = "deprecated_feature" if message == "feature is deprecated" + code = "deprecated_variable" if message == "variable is deprecated" + code = "feature_not_found" if message == "feature not found" + code = "variable_not_found" if message == "variable schema not found" + code = "no_variations" if message == "no variations" + code = "invalid_bucket_by" if message == "invalid bucketBy" + report_diagnostic(level: level, code: code.to_s, message: message, details: details) + end + private :handle_internal_log + # Set the datafile # @param datafile [Hash, String] Datafile content or JSON string # @param replace [Boolean] Whether to replace instead of merge @@ -131,6 +144,26 @@ def get_revision @datafile_reader.get_revision end + def get_schema_version + @datafile_reader.get_schema_version + end + + def get_segment(segment_key) + @datafile_reader.get_segment(segment_key) + end + + def get_feature_keys + @datafile_reader.get_feature_keys + end + + def get_variable_keys(feature_key) + @datafile_reader.get_variable_keys(feature_key) + end + + def has_variations?(feature_key) + @datafile_reader.has_variations?(feature_key) + end + # Get a feature by key # @param feature_key [String] Feature key # @return [Hash, nil] Feature data or nil if not found @@ -573,7 +606,11 @@ def report_diagnostic(diagnostic, source_module = nil) end end else - @logger.log(diagnostic[:level], diagnostic[:message], diagnostic) + Logger.new(level: @logger.level).log( + diagnostic[:level], + diagnostic[:message], + diagnostic + ) end if %w[error fatal].include?(diagnostic[:level]) @@ -595,7 +632,7 @@ def should_report_diagnostic?(diagnostic_level, subscriber_level) # Create a new Featurevisor instance # @param options [Hash] Instance options # @return [Instance] New instance - def self.create_instance(options = {}) + def self.create_featurevisor(options = {}) Instance.new(options) end end diff --git a/lib/featurevisor/logger.rb b/lib/featurevisor/logger.rb index b0a5c07..d76a6b3 100644 --- a/lib/featurevisor/logger.rb +++ b/lib/featurevisor/logger.rb @@ -113,13 +113,6 @@ def default_log_handler(level, message, details = nil) end end - # Create a new logger instance - # @param options [Hash] Logger options - # @return [Logger] New logger instance - def self.create_logger(options = {}) - Logger.new(options) - end - # Default log handler function # @param level [String] Log level # @param message [String] Log message diff --git a/spec/bucketer_spec.rb b/spec/bucketer_spec.rb index 1627b78..7caf762 100644 --- a/spec/bucketer_spec.rb +++ b/spec/bucketer_spec.rb @@ -38,7 +38,7 @@ end describe "get_bucket_key" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } it "should be a method" do expect(Featurevisor::Bucketer).to respond_to(:get_bucket_key) diff --git a/spec/child_instance_spec.rb b/spec/child_instance_spec.rb index a433c37..146a948 100644 --- a/spec/child_instance_spec.rb +++ b/spec/child_instance_spec.rb @@ -2,7 +2,7 @@ RSpec.describe "sdk: child" do it "should create a child instance" do - f = Featurevisor.create_instance( + f = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", diff --git a/spec/conditions_spec.rb b/spec/conditions_spec.rb index 6666bb6..9472e51 100644 --- a/spec/conditions_spec.rb +++ b/spec/conditions_spec.rb @@ -1,7 +1,7 @@ require "featurevisor" RSpec.describe Featurevisor::Conditions do - let(:logger) { Featurevisor.create_logger } + let(:logger) { Featurevisor.const_get(:Logger).new } let(:datafile_reader) do Featurevisor.const_get(:DatafileReader).new( datafile: { diff --git a/spec/conformance_spec.rb b/spec/conformance_spec.rb index 5770a7c..742a5e6 100644 --- a/spec/conformance_spec.rb +++ b/spec/conformance_spec.rb @@ -8,7 +8,7 @@ fixture = JSON.parse(File.read(File.expand_path("../conformance/sdk-v3.json", __dir__)), symbolize_names: true) reader = Featurevisor.const_get(:DatafileReader).new( datafile: { schemaVersion: "2", revision: "conformance", segments: {}, features: {} }, - logger: Featurevisor::Logger.new(level: "fatal") + logger: Featurevisor.const_get(:Logger).new(level: "fatal") ) traffic = { allocation: fixture.dig(:bucketing, :allocations) } diff --git a/spec/datafile_reader_spec.rb b/spec/datafile_reader_spec.rb index a1d7d61..071ae68 100644 --- a/spec/datafile_reader_spec.rb +++ b/spec/datafile_reader_spec.rb @@ -1,7 +1,7 @@ require "featurevisor" RSpec.describe Featurevisor.const_get(:DatafileReader) do - let(:logger) { Featurevisor.create_logger } + let(:logger) { Featurevisor.const_get(:Logger).new } describe "basic functionality" do it "should be a class" do diff --git a/spec/evaluate_spec.rb b/spec/evaluate_spec.rb index 7e3e1bb..3eb251c 100644 --- a/spec/evaluate_spec.rb +++ b/spec/evaluate_spec.rb @@ -38,7 +38,7 @@ end describe "evaluate_with_modules" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } let(:datafile_reader) do Featurevisor.const_get(:DatafileReader).new( datafile: { @@ -131,7 +131,7 @@ end describe "evaluate" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } let(:datafile_reader) do Featurevisor.const_get(:DatafileReader).new( datafile: { diff --git a/spec/events_spec.rb b/spec/events_spec.rb index 09baf6e..d47d548 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -1,7 +1,7 @@ require "featurevisor" RSpec.describe Featurevisor::Events do - let(:logger) { Featurevisor.create_logger(level: "error") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "error") } describe ".get_params_for_sticky_set_event" do it "should get params for sticky set event: empty to new" do diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 3466518..d50c03b 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -2,11 +2,13 @@ RSpec.describe "sdk: instance" do it "should be a function" do - expect(Featurevisor.respond_to?(:create_instance)).to be true + expect(Featurevisor.respond_to?(:create_featurevisor)).to be true + expect(Featurevisor.respond_to?(:create_instance)).to be false + expect(Featurevisor.respond_to?(:create_logger)).to be false end it "should create instance with datafile content" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -16,12 +18,14 @@ ) expect(sdk.respond_to?(:get_variation)).to be true + expect(sdk.get_schema_version).to eq("2") + expect(sdk.get_feature_keys).to eq([]) end it "reports lifecycle mutation diagnostics" do diagnostics = [] - sdk = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: "debug"), + sdk = Featurevisor.create_featurevisor( + log_level: "debug", on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } ) @@ -36,7 +40,7 @@ it "should configure plain bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -84,7 +88,7 @@ it "should configure and bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -132,7 +136,7 @@ it "should configure or bucketBy" do captured_bucket_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -195,7 +199,7 @@ intercepted_feature_key = "" intercepted_variable_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -251,7 +255,7 @@ intercepted_feature_key = "" intercepted_variable_key = "" - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -328,7 +332,7 @@ segments: {} } - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( sticky: { test: { enabled: true, @@ -372,7 +376,7 @@ end it "should honour simple required features" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -411,7 +415,7 @@ expect(sdk.is_enabled("myKey")).to be false # enabling required should enable the feature too - sdk2 = Featurevisor.create_instance( + sdk2 = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -450,7 +454,7 @@ it "should honour required features with variation" do # should be disabled because required has different variation - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -497,7 +501,7 @@ expect(sdk.is_enabled("myKey")).to be false # child should be enabled because required has desired variation - sdk2 = Featurevisor.create_instance( + sdk2 = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -546,7 +550,7 @@ it "should emit warnings for deprecated feature" do deprecated_count = 0 - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -587,13 +591,11 @@ }, segments: {} }, - logger: Featurevisor.create_logger( - handler: ->(level, message, details) { - if level == "warn" && message.include?("is deprecated") + on_diagnostic: ->(diagnostic) { + if diagnostic[:code] == "deprecated_feature" deprecated_count += 1 end } - ) ) test_variation = sdk.get_variation("test", { @@ -609,7 +611,7 @@ end it "should check if enabled for overridden flags from rules" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -656,7 +658,7 @@ it "should check if enabled for mutually exclusive features" do bucket_value = 10_000 - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( modules: [ { name: "unit-test", @@ -691,7 +693,7 @@ end it "should get variation" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -770,7 +772,7 @@ end it "should get variable" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1080,7 +1082,7 @@ end it "should get variables without any variations" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1150,7 +1152,7 @@ end it "should check if enabled for individually named segments" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1241,7 +1243,7 @@ "segments": {} }' - sdk = Featurevisor.create_instance(datafile: json_datafile) + sdk = Featurevisor.create_featurevisor(datafile: json_datafile) expect(sdk.get_revision).to eq("1.0") expect(sdk.get_feature("test")).to be_a(Hash) @@ -1252,7 +1254,7 @@ end it "should handle JSON string when setting datafile" do - sdk = Featurevisor.create_instance + sdk = Featurevisor.create_featurevisor json_datafile = '{ "schemaVersion": "2", @@ -1306,7 +1308,7 @@ # Parse with symbolize_names: true as documented datafile = JSON.parse(json_string, symbolize_names: true) - sdk = Featurevisor.create_instance(datafile: datafile) + sdk = Featurevisor.create_featurevisor(datafile: datafile) expect(sdk.get_revision).to eq("3.0") expect(sdk.get_feature("manualTest")).to be_a(Hash) @@ -1315,7 +1317,7 @@ end it "should merge datafiles by default and preserve featurevisorVersion" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1348,13 +1350,14 @@ ) expect(sdk.get_revision).to eq("2.0") - expect(sdk.datafile_reader.featurevisor_version).to eq("3.1.0") + reader = sdk.instance_variable_get(:@datafile_reader) + expect(reader.featurevisor_version).to eq("3.1.0") expect(sdk.get_feature("firstFeature")).to be_a(Hash) expect(sdk.get_feature("secondFeature")).to be_a(Hash) end it "should replace datafile when replace is true" do - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", @@ -1391,7 +1394,7 @@ it "should include replaced flag in datafile_set events" do events = [] - sdk = Featurevisor.create_instance + sdk = Featurevisor.create_featurevisor sdk.on("datafile_set", ->(event) { events << event }) sdk.set_datafile({ schemaVersion: "2", revision: "1.0", segments: {}, features: {} }) @@ -1404,7 +1407,7 @@ setup_revision = nil closed = false - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( modules: [ { name: "lifecycle", @@ -1424,8 +1427,8 @@ it "should report duplicate module diagnostics and emit error events" do diagnostics = [] errors = [] - sdk = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: "error"), + sdk = Featurevisor.create_featurevisor( + log_level: "error", on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, modules: [{ name: "duplicate" }] ) @@ -1448,8 +1451,8 @@ errors = [] closed = [] - sdk = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: "error"), + sdk = Featurevisor.create_featurevisor( + log_level: "error", on_diagnostic: ->(diagnostic) { diagnostics << diagnostic }, modules: [ { @@ -1485,8 +1488,8 @@ it "should report module close errors from unsubscribe once" do diagnostics = [] - sdk = Featurevisor.create_instance( - logger: Featurevisor.create_logger(level: "error"), + sdk = Featurevisor.create_featurevisor( + log_level: "error", on_diagnostic: ->(diagnostic) { diagnostics << diagnostic } ) @@ -1505,7 +1508,7 @@ received_by_instance = [] received_by_module = [] - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( on_diagnostic: ->(diagnostic) { received_by_instance << diagnostic }, modules: [ { @@ -1537,7 +1540,7 @@ sdk = nil expect { - sdk = Featurevisor.create_instance( + sdk = Featurevisor.create_featurevisor( on_diagnostic: ->(_diagnostic) { raise "handler failed" } ) sdk.is_enabled("missing", {}) @@ -1547,7 +1550,7 @@ describe "get_value_by_type" do let(:sdk) do - Featurevisor.create_instance( + Featurevisor.create_featurevisor( datafile: { schemaVersion: "2", revision: "1.0", diff --git a/spec/logger_spec.rb b/spec/logger_spec.rb index a7a521d..d4ceb4e 100644 --- a/spec/logger_spec.rb +++ b/spec/logger_spec.rb @@ -1,6 +1,6 @@ require "featurevisor" -RSpec.describe Featurevisor::Logger do +RSpec.describe Featurevisor.const_get(:Logger) do let(:console_output) { StringIO.new } let(:original_stdout) { $stdout } let(:original_stderr) { $stderr } @@ -17,20 +17,20 @@ describe "create_logger" do it "should create a logger with default options" do - logger = Featurevisor.create_logger - expect(logger).to be_instance_of(Featurevisor::Logger) + logger = Featurevisor.const_get(:Logger).new + expect(logger).to be_instance_of(Featurevisor.const_get(:Logger)) end it "should create a logger with custom level" do - logger = Featurevisor.create_logger(level: "debug") - expect(logger).to be_instance_of(Featurevisor::Logger) + logger = Featurevisor.const_get(:Logger).new(level: "debug") + expect(logger).to be_instance_of(Featurevisor.const_get(:Logger)) end it "should create a logger with custom handler" do custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", nil) - logger = Featurevisor.create_logger(handler: custom_handler) + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler) logger.info("test message") end end @@ -38,7 +38,7 @@ describe "Logger" do describe "constructor" do it "should use default log level when none provided" do - logger = Featurevisor::Logger.new + logger = Featurevisor.const_get(:Logger).new logger.debug("debug message") # Debug should not be logged with default level (info) @@ -46,7 +46,7 @@ end it "should use provided log level" do - logger = Featurevisor::Logger.new(level: "debug") + logger = Featurevisor.const_get(:Logger).new(level: "debug") logger.debug("debug message") # Debug should be logged with debug level @@ -55,7 +55,7 @@ end it "should use default handler when none provided" do - logger = Featurevisor::Logger.new + logger = Featurevisor.const_get(:Logger).new logger.info("test message") expect(console_output.string).to include("[Featurevisor]") @@ -66,14 +66,14 @@ custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", nil) - logger = Featurevisor::Logger.new(handler: custom_handler) + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler) logger.info("test message") end end describe "set_level" do it "should update the log level" do - logger = Featurevisor::Logger.new(level: "info") + logger = Featurevisor.const_get(:Logger).new(level: "info") # Debug should not be logged initially logger.debug("debug message") @@ -92,14 +92,14 @@ levels.each do |level| console_output.truncate(0) - logger = Featurevisor::Logger.new(level: level) + logger = Featurevisor.const_get(:Logger).new(level: level) logger.error("error message") expect(console_output.string).to include("error message") end end it "should log warn messages at warn level and above" do - logger = Featurevisor::Logger.new(level: "warn") + logger = Featurevisor.const_get(:Logger).new(level: "warn") logger.warn("warn message") expect(console_output.string).to include("warn message") @@ -109,21 +109,21 @@ end it "should not log info messages at warn level" do - logger = Featurevisor::Logger.new(level: "warn") + logger = Featurevisor.const_get(:Logger).new(level: "warn") logger.info("info message") expect(console_output.string).not_to include("info message") end it "should not log debug messages at info level" do - logger = Featurevisor::Logger.new(level: "info") + logger = Featurevisor.const_get(:Logger).new(level: "info") logger.debug("debug message") expect(console_output.string).not_to include("debug message") end it "should log all messages at debug level" do - logger = Featurevisor::Logger.new(level: "debug") + logger = Featurevisor.const_get(:Logger).new(level: "debug") logger.debug("debug message") expect(console_output.string).to include("debug message") @@ -140,7 +140,7 @@ end describe "convenience methods" do - let(:logger) { Featurevisor::Logger.new(level: "debug") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "debug") } it "should call debug method correctly" do logger.debug("debug message") @@ -183,7 +183,7 @@ custom_handler = double("custom_handler") expect(custom_handler).to receive(:call).with("info", "test message", { test: true }) - logger = Featurevisor::Logger.new(handler: custom_handler, level: "debug") + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler, level: "debug") details = { test: true } logger.log("info", "test message", details) @@ -193,7 +193,7 @@ custom_handler = double("custom_handler") expect(custom_handler).not_to receive(:call) - logger = Featurevisor::Logger.new(handler: custom_handler, level: "warn") + logger = Featurevisor.const_get(:Logger).new(handler: custom_handler, level: "warn") logger.log("debug", "debug message") end end diff --git a/spec/modules_spec.rb b/spec/modules_spec.rb index fc76b77..990cbd1 100644 --- a/spec/modules_spec.rb +++ b/spec/modules_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Featurevisor::Modules do describe "Module" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } it "should be a class" do expect(Featurevisor::Modules::FeaturevisorModule).to be_a(Class) @@ -123,7 +123,7 @@ end describe "ModulesManager" do - let(:logger) { Featurevisor.create_logger(level: "warn") } + let(:logger) { Featurevisor.const_get(:Logger).new(level: "warn") } let(:diagnostics) { [] } let(:modules_manager) do Featurevisor::Modules::ModulesManager.new( From 70ec50f11f79949bb1b41550ec78bfddd9a1ad19 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:39:54 +0200 Subject: [PATCH 13/16] alignment --- README.md | 7 ++++--- lib/featurevisor/instance.rb | 8 ++++---- lib/featurevisor/modules.rb | 2 +- spec/instance_spec.rb | 4 ++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4d14e81..aa77fa1 100644 --- a/README.md +++ b/README.md @@ -557,14 +557,15 @@ end ```ruby unsubscribe = f.on('error') do |event| - code = event[:code] - message = event[:message] + diagnostic = event[:diagnostic] + code = diagnostic[:code] + message = diagnostic[:message] puts "Featurevisor error: #{code} #{message}" end ``` -The `error` event is emitted for diagnostics reported with `level: "error"` or `level: "fatal"`. +The `error` event is emitted for diagnostics reported with `level: "error"`. ## Evaluation details diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index 2897487..d5ccf18 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -55,7 +55,7 @@ def initialize(options = {}) report_diagnostic( level: "info", code: "sdk_initialized", - message: "Featurevisor SDK initialized" + message: "SDK initialized" ) end @@ -95,13 +95,13 @@ def set_datafile(datafile, replace = false) details = Featurevisor::Events.get_params_for_datafile_set_event(@datafile_reader, new_datafile_reader, replace) @datafile_reader = new_datafile_reader - @emitter.trigger("datafile_set", details) report_diagnostic( level: "info", code: "datafile_set", message: "Datafile set", details: details ) + @emitter.trigger("datafile_set", details) rescue => e report_diagnostic( level: "error", @@ -613,8 +613,8 @@ def report_diagnostic(diagnostic, source_module = nil) ) end - if %w[error fatal].include?(diagnostic[:level]) - @emitter.trigger("error", diagnostic) + if diagnostic[:level] == "error" + @emitter.trigger("error", diagnostic: diagnostic) end end diff --git a/lib/featurevisor/modules.rb b/lib/featurevisor/modules.rb index 678fec6..650eceb 100644 --- a/lib/featurevisor/modules.rb +++ b/lib/featurevisor/modules.rb @@ -78,7 +78,7 @@ def add(mod) message: "Duplicate module name", module_name: mod.name }, - mod + nil ) return nil end diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index d50c03b..97d6ad7 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1443,7 +1443,7 @@ moduleName: "duplicate", details: {} ) - expect(errors.last).to include(code: "duplicate_module") + expect(errors.last).to include(diagnostic: include(code: "duplicate_module")) end it "should report module close errors and keep closing remaining modules" do @@ -1482,7 +1482,7 @@ details: {} ) ) - expect(errors).to include(include(code: "module_close_error", moduleName: "first")) + expect(errors).to include(include(diagnostic: include(code: "module_close_error", moduleName: "first"))) end it "should report module close errors from unsubscribe once" do From c819c544eff84c74c06e14269dd0ee739b1a8061 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 20:49:26 +0200 Subject: [PATCH 14/16] alignment --- README.md | 4 ++-- lib/featurevisor/events.rb | 4 ++-- lib/featurevisor/instance.rb | 15 +++++++++++++-- spec/cli_spec.rb | 3 +++ spec/events_spec.rb | 12 ++++++------ spec/instance_spec.rb | 7 +++++++ 6 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index aa77fa1..0faed19 100644 --- a/README.md +++ b/README.md @@ -507,8 +507,8 @@ You can listen to these events that can occur at various stages in your applicat ```ruby unsubscribe = f.on('datafile_set') do |event| revision = event[:revision] # new revision - previous_revision = event[:previous_revision] - revision_changed = event[:revision_changed] # true if revision has changed + previous_revision = event[:previousRevision] + revision_changed = event[:revisionChanged] # true if revision has changed # list of feature keys that have new updates, # and you should re-evaluate them diff --git a/lib/featurevisor/events.rb b/lib/featurevisor/events.rb index 1190a46..511324b 100644 --- a/lib/featurevisor/events.rb +++ b/lib/featurevisor/events.rb @@ -67,8 +67,8 @@ def self.get_params_for_datafile_set_event(previous_reader, new_reader, replace { revision: new_revision, - previous_revision: previous_revision, - revision_changed: previous_revision != new_revision, + previousRevision: previous_revision, + revisionChanged: previous_revision != new_revision, features: all_affected_features, replaced: replace } diff --git a/lib/featurevisor/instance.rb b/lib/featurevisor/instance.rb index d5ccf18..705a7e2 100644 --- a/lib/featurevisor/instance.rb +++ b/lib/featurevisor/instance.rb @@ -85,7 +85,18 @@ def set_datafile(datafile, replace = false) return if @closed begin - parsed_datafile = datafile.is_a?(String) ? JSON.parse(datafile, symbolize_names: true) : datafile + parsed_datafile = if datafile.is_a?(String) + JSON.parse(datafile, symbolize_names: true) + elsif datafile.is_a?(Hash) + JSON.parse(JSON.generate(datafile), symbolize_names: true) + else + datafile + end + unless parsed_datafile.is_a?(Hash) && parsed_datafile[:schemaVersion].is_a?(String) && + parsed_datafile[:revision].is_a?(String) && parsed_datafile[:segments].is_a?(Hash) && + parsed_datafile[:features].is_a?(Hash) + raise ArgumentError, "Invalid datafile" + end next_datafile = replace ? parsed_datafile : merge_datafiles(@datafile_reader.get_datafile, parsed_datafile) new_datafile_reader = DatafileReader.new( datafile: next_datafile, @@ -572,7 +583,7 @@ def clear_module_diagnostic_subscriptions(mod) def report_diagnostic(diagnostic, source_module = nil) diagnostic = (diagnostic || {}).dup diagnostic[:level] ||= "info" - diagnostic[:module] ||= source_module.name if source_module && source_module.name + diagnostic[:module] = source_module.name if source_module && source_module.name details = (diagnostic[:details] || {}).dup legacy_module_name = diagnostic.delete(:module_name) legacy_original_error = diagnostic.delete(:original_error) diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index ed7bb06..cccd688 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -114,6 +114,9 @@ it "accepts valid parameters" do # Mock the build_datafile method to avoid external command execution allow_any_instance_of(FeaturevisorCLI::Commands::Benchmark).to receive(:build_datafile).and_return({ + "schemaVersion" => "2", + "revision" => "test", + "segments" => {}, "features" => { "testFeature" => { "key" => "testFeature", diff --git a/spec/events_spec.rb b/spec/events_spec.rb index d47d548..090741f 100644 --- a/spec/events_spec.rb +++ b/spec/events_spec.rb @@ -75,8 +75,8 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, + previousRevision: "1", + revisionChanged: true, features: %i[feature1 feature2], replaced: false }) @@ -103,8 +103,8 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, + previousRevision: "1", + revisionChanged: true, features: %i[feature2 feature3], replaced: false }) @@ -129,8 +129,8 @@ def build_reader(revision:, features:) expect(result).to eq({ revision: "2", - previous_revision: "1", - revision_changed: true, + previousRevision: "1", + revisionChanged: true, features: %i[feature1 feature2], replaced: false }) diff --git a/spec/instance_spec.rb b/spec/instance_spec.rb index 97d6ad7..2858aad 100644 --- a/spec/instance_spec.rb +++ b/spec/instance_spec.rb @@ -1403,6 +1403,13 @@ expect(events.map { |event| event[:replaced] }).to eq([false, true]) end + it "accepts a datafile hash with string keys" do + sdk = Featurevisor.create_featurevisor + sdk.set_datafile({ "schemaVersion" => "2", "revision" => "string-keys", "segments" => {}, "features" => {} }) + + expect(sdk.get_revision).to eq("string-keys") + end + it "should run module setup and close lifecycle callbacks" do setup_revision = nil closed = false From 7f9aca6a4c1f8150efe7c7e3364fbfb272a0cf8a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 12 Jul 2026 23:54:53 +0200 Subject: [PATCH 15/16] updates --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c55d7bd..3a11b6d 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ test: test-example-1: bundle exec rspec spec/ - bundle exec ruby bin/featurevisor test --projectDirectoryPath=/Users/fahad/Projects/featurevisor/featurevisor/examples/example-1 --onlyFailures + bundle exec ruby bin/featurevisor test --projectDirectoryPath=../featurevisor/examples/example-1 --onlyFailures setup-monorepo: mkdir -p monorepo From ecea96fa388e7321a57c091a8c9df867d62b7ea9 Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Mon, 13 Jul 2026 23:40:23 +0200 Subject: [PATCH 16/16] prepare for v1 release --- Gemfile.lock | 2 +- lib/featurevisor/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2f9ac65..ef4da6e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - featurevisor (0.3.0) + featurevisor (1.0.0) benchmark GEM diff --git a/lib/featurevisor/version.rb b/lib/featurevisor/version.rb index ddf7ce1..90d5e0f 100644 --- a/lib/featurevisor/version.rb +++ b/lib/featurevisor/version.rb @@ -1,3 +1,3 @@ module Featurevisor - VERSION = "0.3.0" + VERSION = "1.0.0" end