diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c981835..17ad7dd 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -22,7 +22,7 @@ jobs: - '3.4' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Set up Ruby uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0 with: @@ -31,7 +31,36 @@ jobs: - name: Run tests run: bundle exec rake - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 with: token: ${{ secrets.CODECOV_TOKEN }} slug: mixpanel/mixpanel-ruby + + test-openfeature: + + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: + - '3.1' + - '3.2' + - '3.3' + - '3.4' + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Set up Ruby + uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0 + with: + ruby-version: ${{ matrix.ruby-version }} + - name: Install dependencies + run: cd openfeature-provider && bundle install + - name: Run OpenFeature provider tests + run: cd openfeature-provider && bundle exec rspec + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: mixpanel/mixpanel-ruby + flags: openfeature + directory: openfeature-provider diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 3984b4c..116f011 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -56,25 +56,29 @@ def call_flags_endpoint(additional_params = nil) request.basic_auth(@provider_config[:token], '') request['Content-Type'] = 'application/json' - request['traceparent'] = Utils.generate_traceparent() + request['traceparent'] = Utils.generate_traceparent begin response = http.request(request) + rescue Net::OpenTimeout, Net::ReadTimeout => e + raise ConnectionError.new("Request timeout: #{e.message}") + rescue StandardError => e + raise ConnectionError.new("Network error: #{e.message}") + end - unless response.code.to_i == 200 - raise ServerError.new("HTTP #{response.code}: #{response.body}") - end + unless response.code == '200' + raise ServerError.new("HTTP #{response.code}: #{response.body}") + end + begin JSON.parse(response.body) - rescue Net::OpenTimeout, Net::ReadTimeout => e - raise ConnectionError.new("Request timeout: #{e.message}") rescue JSON::ParserError => e raise ServerError.new("Invalid JSON response: #{e.message}") - rescue StandardError => e - raise ConnectionError.new("Network error: #{e.message}") end end + def shutdown; end + # Track exposure event to Mixpanel # @param flag_key [String] Feature flag key # @param selected_variant [SelectedVariant] The selected variant diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index e1f1ab2..9bf1bb0 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -64,6 +64,10 @@ def stop_polling_for_definitions! @polling_thread = nil end + def shutdown + stop_polling_for_definitions! + end + # Check if flag is enabled (for boolean flags) # @param flag_key [String] Feature flag key # @param context [Hash] Evaluation context (must include 'distinct_id') @@ -107,24 +111,17 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) context_value = context[context_key] || context[context_key.to_sym] - selected_variant = nil + selected_variant = get_variant_override_for_test_user(flag, context) - test_variant = get_variant_override_for_test_user(flag, context) - if test_variant - selected_variant = test_variant - else + unless selected_variant rollout = get_assigned_rollout(flag, context_value, context) - if rollout - selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) - end + selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout end - if selected_variant - track_exposure_event(flag_key, selected_variant, context) if report_exposure - return selected_variant - end + return fallback_variant unless selected_variant - fallback_variant + track_exposure_event(flag_key, selected_variant, context) if report_exposure + selected_variant end # Get all variants for user context @@ -147,11 +144,9 @@ def get_all_variants(context) def fetch_flag_definitions response = call_flags_endpoint - new_definitions = {} - (response['flags'] || []).each do |flag_data| - new_definitions[flag_data['key']] = flag_data + new_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions| + definitions[flag_data['key']] = flag_data end - @flag_definitions = new_definitions response @@ -175,9 +170,10 @@ def get_variant_override_for_test_user(flag, context) end def get_matching_variant(variant_key, flag) - return nil unless flag['ruleset'] && flag['ruleset']['variants'] + variants = flag.dig('ruleset', 'variants') + return nil unless variants - flag['ruleset']['variants'].each do |v| + variants.each do |v| if variant_key.downcase == v['key'].downcase return SelectedVariant.new( variant_key: v['key'], @@ -191,9 +187,10 @@ def get_matching_variant(variant_key, flag) end def get_assigned_rollout(flag, context_value, context) - return nil unless flag['ruleset'] && flag['ruleset']['rollout'] + rollouts = flag.dig('ruleset', 'rollout') + return nil unless rollouts - flag['ruleset']['rollout'].each_with_index do |rollout, index| + rollouts.each_with_index do |rollout, index| salt = if flag['hash_salt'] "#{flag['key']}#{flag['hash_salt']}#{index}" else @@ -224,7 +221,7 @@ def get_assigned_variant(flag, context_value, flag_key, rollout) salt = "#{flag_key}#{stored_salt}variant" variant_hash = Utils.normalized_hash(context_value.to_s, salt) - variants = flag['ruleset']['variants'].map { |v| v.dup } + variants = flag['ruleset']['variants'].map(&:dup) if rollout['variant_splits'] variants.each do |v| v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key']) diff --git a/lib/mixpanel-ruby/version.rb b/lib/mixpanel-ruby/version.rb index 1a673ea..db5c113 100644 --- a/lib/mixpanel-ruby/version.rb +++ b/lib/mixpanel-ruby/version.rb @@ -1,3 +1,3 @@ module Mixpanel - VERSION = '3.0.0' + VERSION = '3.1.0' end diff --git a/openfeature-provider/Gemfile b/openfeature-provider/Gemfile new file mode 100644 index 0000000..5b28494 --- /dev/null +++ b/openfeature-provider/Gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec + +gem 'mixpanel-ruby', path: '..' diff --git a/openfeature-provider/README.md b/openfeature-provider/README.md new file mode 100644 index 0000000..503c778 --- /dev/null +++ b/openfeature-provider/README.md @@ -0,0 +1,286 @@ +# mixpanel-ruby-openfeature + +[![Gem Version](https://img.shields.io/gem/v/mixpanel-ruby-openfeature.svg)](https://rubygems.org/gems/mixpanel-ruby-openfeature) +[![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/) +[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/mixpanel/mixpanel-ruby/blob/master/LICENSE) + +An [OpenFeature](https://openfeature.dev/) provider that wraps Mixpanel's feature flags for use with the OpenFeature Ruby SDK. This allows you to use Mixpanel's feature flagging capabilities through OpenFeature's standardized, vendor-agnostic API. + +## Overview + +This gem provides a bridge between Mixpanel's native feature flags implementation and the OpenFeature specification. By using this provider, you can: + +- Leverage Mixpanel's powerful feature flag and experimentation platform +- Use OpenFeature's standardized API for flag evaluation +- Easily switch between feature flag providers without changing your application code +- Integrate with OpenFeature's ecosystem of tools and frameworks + +## Installation + +Add these gems to your `Gemfile`: + +```ruby +gem 'mixpanel-ruby-openfeature' +gem 'openfeature-sdk' +gem 'mixpanel-ruby' +``` + +Then run: + +```bash +bundle install +``` + +Or install directly: + +```bash +gem install mixpanel-ruby-openfeature +``` + +## Quick Start + +### Local Evaluation (Recommended) + +Local evaluation downloads flag definitions and evaluates them locally, providing fast, synchronous flag checks with no per-evaluation network requests. + +```ruby +require 'mixpanel-ruby' +require 'mixpanel/openfeature' + +# 1. Create the provider with local evaluation +provider = Mixpanel::OpenFeature::Provider.from_local( + 'YOUR_PROJECT_TOKEN', + { poll_interval: 300 } # poll for updated definitions every 300 seconds +) + +# 2. Register the provider with OpenFeature +OpenFeature::SDK.configure do |config| + config.set_provider(provider) +end + +# 3. Get a client and evaluate flags +client = OpenFeature::SDK.build_client + +show_new_feature = client.fetch_boolean_value(flag_key: 'new-feature-flag', default_value: false) + +if show_new_feature + puts 'New feature is enabled!' +end +``` + +### Remote Evaluation + +Remote evaluation sends each flag check to Mixpanel's servers, which is useful when you need server-side targeting or cannot download flag definitions locally. + +```ruby +require 'mixpanel-ruby' +require 'mixpanel/openfeature' + +# 1. Create the provider with remote evaluation +provider = Mixpanel::OpenFeature::Provider.from_remote( + 'YOUR_PROJECT_TOKEN', + {} # remote config options +) + +# 2. Register the provider with OpenFeature +OpenFeature::SDK.configure do |config| + config.set_provider(provider) +end + +# 3. Evaluate flags the same way +client = OpenFeature::SDK.build_client +value = client.fetch_string_value(flag_key: 'button-color-test', default_value: 'blue') +``` + +### Manual Initialization + +If you already have a Mixpanel `Tracker` instance, you can pass its flags provider directly: + +```ruby +tracker = Mixpanel::Tracker.new('YOUR_PROJECT_TOKEN', nil, local_flags_config: { poll_interval: 300 }) +flags_provider = tracker.local_flags +flags_provider.start_polling_for_definitions! + +provider = Mixpanel::OpenFeature::Provider.new(flags_provider) +``` + +## Usage Examples + +### Basic Boolean Flag + +```ruby +client = OpenFeature::SDK.build_client + +is_feature_enabled = client.fetch_boolean_value(flag_key: 'my-feature', default_value: false) + +if is_feature_enabled + # Show the new feature +end +``` + +### Mixpanel Flag Types and OpenFeature Evaluation Methods + +Mixpanel feature flags support three flag types. Use the corresponding OpenFeature evaluation method based on your flag's variant values: + +| Mixpanel Flag Type | Variant Values | OpenFeature Method | +|---|---|---| +| Feature Gate | `true` / `false` | `fetch_boolean_value` | +| Experiment | boolean, string, number, or JSON object | `fetch_boolean_value`, `fetch_string_value`, `fetch_number_value`, or `fetch_object_value` | +| Dynamic Config | JSON object | `fetch_object_value` | + +```ruby +client = OpenFeature::SDK.build_client + +# Feature Gate - boolean variants +is_feature_on = client.fetch_boolean_value(flag_key: 'new-checkout', default_value: false) + +# Experiment with string variants +button_color = client.fetch_string_value(flag_key: 'button-color-test', default_value: 'blue') + +# Experiment with number variants +max_items = client.fetch_number_value(flag_key: 'max-items', default_value: 10) + +# Dynamic Config - JSON object variants +feature_config = client.fetch_object_value( + flag_key: 'homepage-layout', + default_value: { 'layout' => 'grid', 'items_per_row' => 3 } +) +``` + +### Getting Full Resolution Details + +If you need additional metadata about the flag evaluation: + +```ruby +client = OpenFeature::SDK.build_client + +details = client.fetch_boolean_details(flag_key: 'my-feature', default_value: false) + +puts details.value # The resolved value +puts details.variant # The variant key from Mixpanel +puts details.reason # Why this value was returned +puts details.error_code # Error code if evaluation failed +``` + +### Passing Evaluation Context + +You can pass evaluation context to provide additional properties for flag evaluation: + +```ruby +context = OpenFeature::SDK::EvaluationContext.new( + targeting_key: 'user-123', + email: 'user@example.com', + plan: 'premium' +) + +value = client.fetch_boolean_value( + flag_key: 'premium-feature', + default_value: false, + evaluation_context: context +) +``` + +### Accessing the Mixpanel Tracker + +When using the `from_local` or `from_remote` class methods, you can access the underlying Mixpanel tracker for tracking events: + +```ruby +provider = Mixpanel::OpenFeature::Provider.from_local('YOUR_PROJECT_TOKEN', {}) + +# Access the tracker for event tracking +provider.mixpanel.track('user-123', 'Page View', { 'page' => '/home' }) +``` + +## Cleanup + +When you are done using the provider, shut it down to stop any background polling: + +```ruby +provider.shutdown +``` + +## Error Handling + +The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation: + +### PROVIDER_NOT_READY + +Returned when flags are evaluated before the provider has finished initializing (e.g., before flag definitions have been fetched for local evaluation). + +### FLAG_NOT_FOUND + +Returned when the requested flag does not exist in Mixpanel. + +```ruby +details = client.fetch_boolean_details(flag_key: 'nonexistent-flag', default_value: false) + +if details.error_code == OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND + puts 'Flag does not exist, using default value' +end +``` + +### TYPE_MISMATCH + +Returned when the flag value type does not match the requested type. + +```ruby +# If 'my-flag' returns a string in Mixpanel but you request a boolean... +details = client.fetch_boolean_details(flag_key: 'my-flag', default_value: false) + +if details.error_code == OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH + puts 'Flag is not a boolean, using default value' +end +``` + +## FAQ + +### What Ruby versions are supported? + +Ruby 3.1.0 or later is required. + +### What is the difference between local and remote evaluation? + +**Local evaluation** downloads all flag definitions upfront and evaluates them in-process. This is faster (no network round-trip per evaluation) and works offline after the initial fetch. Use `from_local` for this mode. + +**Remote evaluation** sends each flag check to Mixpanel's servers. This ensures you always have the latest flag values and supports server-side-only targeting rules. Use `from_remote` for this mode. + +### Does targetingKey have special meaning? + +Unlike some feature flag providers, `targetingKey` is not used as a special bucketing key in Mixpanel. It is passed as another context property alongside all other fields. Mixpanel's server-side configuration determines which properties are used for targeting rules and bucketing. + +### Does the provider call mixpanel.identify()? + +No. User identity should be managed separately through the Mixpanel tracker's `track` or `people` methods. The provider only handles feature flag evaluation. + +### How are exposure events tracked? + +When a flag is successfully resolved, the provider automatically reports an exposure event via `report_exposure: true`. This tracks `$experiment_started` events in Mixpanel for analytics and experimentation reporting. + +### Can I use this with Rails? + +Yes. A common pattern is to initialize the provider in an initializer: + +```ruby +# config/initializers/openfeature.rb +provider = Mixpanel::OpenFeature::Provider.from_local( + ENV['MIXPANEL_TOKEN'], + { poll_interval: 300 } +) + +OpenFeature::SDK.configure do |config| + config.set_provider(provider) +end + +at_exit { provider.shutdown } +``` + +Then use it in controllers or services: + +```ruby +client = OpenFeature::SDK.build_client +show_banner = client.fetch_boolean_value(flag_key: 'show-banner', default_value: false) +``` + +## License + +Apache-2.0 diff --git a/openfeature-provider/RELEASE.md b/openfeature-provider/RELEASE.md new file mode 100644 index 0000000..cab74f9 --- /dev/null +++ b/openfeature-provider/RELEASE.md @@ -0,0 +1,52 @@ +# Releasing the OpenFeature Provider + +The OpenFeature provider (`mixpanel-ruby-openfeature`) is published to RubyGems independently from the core SDK. + +## Release Order + +The OpenFeature provider depends on features added to `mixpanel-ruby` in version 3.1.0+ (e.g., `shutdown` methods on flags providers). You **must** publish the core SDK first: + +1. Publish `mixpanel-ruby` (bump version in `lib/mixpanel-ruby/version.rb`, build, push) +2. Verify the new version is live on https://rubygems.org/gems/mixpanel-ruby +3. Then publish `mixpanel-ruby-openfeature` (steps below) + +If you update the core SDK version, update the dependency constraint in `mixpanel-ruby-openfeature.gemspec` to match. + +## Prerequisites + +- Ruby 3.1+ +- A RubyGems account with permission to push to the `mixpanel-ruby-openfeature` gem + - For the first upload, you'll need owner access or to create the gem under the Mixpanel org + - Sign in and get your API key at https://rubygems.org/profile/edit + +## Releasing + +1. Update the version in `mixpanel-ruby-openfeature.gemspec` + +2. Build the gem: + ```bash + cd openfeature-provider + gem build mixpanel-ruby-openfeature.gemspec + ``` + +3. Verify the built artifact: + ```bash + ls *.gem + # Should show: mixpanel-ruby-openfeature-.gem + ``` + +4. Push to RubyGems: + ```bash + gem push mixpanel-ruby-openfeature-.gem + ``` + You'll be prompted for your RubyGems credentials on first push. Alternatively, configure `~/.gem/credentials` with your API key: + ```yaml + --- + :rubygems_api_key: rubygems_ + ``` + +5. Verify at https://rubygems.org/gems/mixpanel-ruby-openfeature + +## Versioning + +The OpenFeature provider is versioned independently from the core SDK. The core SDK dependency is declared in the gemspec (`mixpanel-ruby ~> 3.0`) — update it when the provider needs features from a newer core SDK release. diff --git a/openfeature-provider/lib/mixpanel/openfeature.rb b/openfeature-provider/lib/mixpanel/openfeature.rb new file mode 100644 index 0000000..5fb6ba2 --- /dev/null +++ b/openfeature-provider/lib/mixpanel/openfeature.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require_relative 'openfeature/provider' diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb new file mode 100644 index 0000000..38aa349 --- /dev/null +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'open_feature/sdk' + +module Mixpanel + module OpenFeature + class Provider + attr_reader :metadata, :mixpanel + + def self.from_local(token, config, error_handler: nil) + tracker = ::Mixpanel::Tracker.new(token, error_handler, local_flags_config: config) + flags_provider = tracker.local_flags + flags_provider.start_polling_for_definitions! + provider = new(flags_provider) + provider.instance_variable_set(:@mixpanel, tracker) + provider + end + + def self.from_remote(token, config, error_handler: nil) + tracker = ::Mixpanel::Tracker.new(token, error_handler, remote_flags_config: config) + flags_provider = tracker.remote_flags + provider = new(flags_provider) + provider.instance_variable_set(:@mixpanel, tracker) + provider + end + + def initialize(flags_provider) + @flags_provider = flags_provider + @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: 'mixpanel-provider').freeze + end + + def shutdown + @flags_provider.shutdown if @flags_provider.respond_to?(:shutdown) + end + + def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :boolean, evaluation_context) + end + + def fetch_string_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :string, evaluation_context) + end + + def fetch_number_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :number, evaluation_context) + end + + def fetch_integer_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :integer, evaluation_context) + end + + def fetch_float_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :float, evaluation_context) + end + + def fetch_object_value(flag_key:, default_value:, evaluation_context: nil) + resolve(flag_key, default_value, :object, evaluation_context) + end + + private + + def resolve(flag_key, default_value, expected_type, evaluation_context) + unless flags_ready? + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::PROVIDER_NOT_READY) + end + + context = build_context(evaluation_context) + fallback = ::Mixpanel::Flags::SelectedVariant.new(variant_value: default_value) + + begin + result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true) + rescue StandardError + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end + + if result.equal?(fallback) + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND, + reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT + ) + end + + value = result.variant_value + + if value.nil? && expected_type != :object + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + end + + coerced = coerce_value(value, expected_type) + if coerced.nil? && !value.nil? + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::TYPE_MISMATCH) + end + + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: coerced.nil? ? value : coerced, + variant: result.variant_key, + reason: ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH + ) + end + + def coerce_value(value, expected_type) + case expected_type + when :boolean + value == true || value == false ? value : nil + when :string + value.is_a?(String) ? value : nil + when :integer + if value.is_a?(Integer) + value + elsif value.is_a?(Float) && value.finite? && value == value.floor + value.to_i + end + when :float + if value.is_a?(Float) + value + elsif value.is_a?(Integer) + value.to_f + end + when :number + value.is_a?(Numeric) ? value : nil + when :object + value + end + end + + def build_context(evaluation_context) + return {} if evaluation_context.nil? + + ctx = {} + if evaluation_context.respond_to?(:fields) + evaluation_context.fields.each { |k, v| ctx[k] = unwrap_value(v) } + end + if evaluation_context.respond_to?(:targeting_key) && evaluation_context.targeting_key + ctx['targetingKey'] = unwrap_value(evaluation_context.targeting_key) + end + ctx + end + + def unwrap_value(value) + case value + when Float + value.finite? && value == value.floor ? value.to_i : value + when Array + value.map { |v| unwrap_value(v) } + when Hash + value.transform_values { |v| unwrap_value(v) } + else + value + end + end + + def flags_ready? + if @flags_provider.respond_to?(:are_flags_ready) + @flags_provider.are_flags_ready + else + true + end + end + + def error_result(default_value, error_code) + ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + error_code: error_code, + reason: ::OpenFeature::SDK::Provider::Reason::ERROR + ) + end + end + end +end diff --git a/openfeature-provider/mixpanel-ruby-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec new file mode 100644 index 0000000..9a496f3 --- /dev/null +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = 'mixpanel-ruby-openfeature' + spec.version = '0.1.0' + spec.authors = ['Mixpanel'] + spec.email = 'support@mixpanel.com' + spec.summary = 'OpenFeature provider for Mixpanel feature flags' + spec.description = 'An OpenFeature provider that wraps the Mixpanel Ruby SDK feature flags' + spec.homepage = 'https://mixpanel.com' + spec.license = 'Apache-2.0' + spec.required_ruby_version = '>= 3.1.0' + + spec.files = Dir['lib/**/*.rb'] + spec.require_paths = ['lib'] + + spec.add_runtime_dependency 'openfeature-sdk', '~> 0.5' + spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.1' + + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'simplecov' + spec.add_development_dependency 'simplecov-cobertura' +end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb new file mode 100644 index 0000000..ea7bb24 --- /dev/null +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -0,0 +1,606 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mixpanel::OpenFeature::Provider do + # --- Factory methods --- + + describe '.from_local' do + it 'creates a provider with a local flags provider and starts polling' do + mock_local_flags = instance_double('LocalFlagsProvider') + allow(mock_local_flags).to receive(:start_polling_for_definitions!) + + mock_tracker = instance_double('Mixpanel::Tracker', local_flags: mock_local_flags) + stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker)) + + config = { polling_interval: 300 } + provider = described_class.from_local('test-token', config) + + expect(Mixpanel::Tracker).to have_received(:new).with('test-token', nil, local_flags_config: config) + expect(mock_local_flags).to have_received(:start_polling_for_definitions!) + expect(provider.mixpanel).to eq(mock_tracker) + expect(provider).to be_a(described_class) + end + + it 'forwards error_handler to the tracker' do + mock_local_flags = instance_double('LocalFlagsProvider') + allow(mock_local_flags).to receive(:start_polling_for_definitions!) + + mock_tracker = instance_double('Mixpanel::Tracker', local_flags: mock_local_flags) + stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker)) + + error_handler = double('ErrorHandler') + described_class.from_local('test-token', {}, error_handler: error_handler) + + expect(Mixpanel::Tracker).to have_received(:new).with('test-token', error_handler, local_flags_config: {}) + end + end + + describe '.from_remote' do + it 'creates a provider with a remote flags provider' do + mock_remote_flags = double('RemoteFlagsProvider') + mock_tracker = instance_double('Mixpanel::Tracker', remote_flags: mock_remote_flags) + stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker)) + + config = { endpoint: 'https://example.com' } + provider = described_class.from_remote('test-token', config) + + expect(Mixpanel::Tracker).to have_received(:new).with('test-token', nil, remote_flags_config: config) + expect(provider.mixpanel).to eq(mock_tracker) + expect(provider).to be_a(described_class) + end + + it 'forwards error_handler to the tracker' do + mock_remote_flags = double('RemoteFlagsProvider') + mock_tracker = instance_double('Mixpanel::Tracker', remote_flags: mock_remote_flags) + stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker)) + + error_handler = double('ErrorHandler') + described_class.from_remote('test-token', {}, error_handler: error_handler) + + expect(Mixpanel::Tracker).to have_received(:new).with('test-token', error_handler, remote_flags_config: {}) + end + end + + # --- Instance behavior --- + + let(:mock_flags) do + instance_double('FlagsProvider').tap do |flags| + allow(flags).to receive(:are_flags_ready).and_return(true) + end + end + + let(:provider) { described_class.new(mock_flags) } + + def setup_flag(flag_key, value, variant_key: 'variant-key') + allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs| + if key == flag_key + Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value) + else + fallback + end + end + end + + def setup_flag_not_found + allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback } + end + + # --- Metadata --- + + describe '#metadata' do + it 'returns mixpanel-provider as the name' do + expect(provider.metadata.name).to eq('mixpanel-provider') + end + end + + # --- Boolean evaluation --- + + describe '#fetch_boolean_value' do + it 'resolves true' do + setup_flag('bool-flag', true) + result = provider.fetch_boolean_value(flag_key: 'bool-flag', default_value: false) + expect(result.value).to be true + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'resolves false' do + setup_flag('bool-flag', false) + result = provider.fetch_boolean_value(flag_key: 'bool-flag', default_value: true) + expect(result.value).to be false + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'returns TYPE_MISMATCH when value is not boolean' do + setup_flag('string-flag', 'not-a-bool') + result = provider.fetch_boolean_value(flag_key: 'string-flag', default_value: false) + expect(result.value).to be false + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + end + + # --- String evaluation --- + + describe '#fetch_string_value' do + it 'resolves a string' do + setup_flag('string-flag', 'hello') + result = provider.fetch_string_value(flag_key: 'string-flag', default_value: 'default') + expect(result.value).to eq('hello') + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'returns TYPE_MISMATCH when value is not string' do + setup_flag('bool-flag', true) + result = provider.fetch_string_value(flag_key: 'bool-flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + end + + # --- Integer evaluation --- + + describe '#fetch_integer_value' do + it 'resolves an integer' do + setup_flag('int-flag', 42) + result = provider.fetch_integer_value(flag_key: 'int-flag', default_value: 0) + expect(result.value).to eq(42) + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'coerces float with no fraction to integer' do + setup_flag('int-flag', 42.0) + result = provider.fetch_integer_value(flag_key: 'int-flag', default_value: 0) + expect(result.value).to eq(42) + expect(result.value).to be_a(Integer) + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'returns TYPE_MISMATCH for float with fraction' do + setup_flag('float-flag', 3.14) + result = provider.fetch_integer_value(flag_key: 'float-flag', default_value: 0) + expect(result.value).to eq(0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'returns TYPE_MISMATCH when value is a string' do + setup_flag('string-flag', 'not-a-number') + result = provider.fetch_integer_value(flag_key: 'string-flag', default_value: 0) + expect(result.value).to eq(0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + end + + # --- Float evaluation --- + + describe '#fetch_float_value' do + it 'resolves a float' do + setup_flag('float-flag', 3.14) + result = provider.fetch_float_value(flag_key: 'float-flag', default_value: 0.0) + expect(result.value).to be_within(0.001).of(3.14) + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'coerces integer to float' do + setup_flag('float-flag', 42) + result = provider.fetch_float_value(flag_key: 'float-flag', default_value: 0.0) + expect(result.value).to eq(42.0) + expect(result.value).to be_a(Float) + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'returns TYPE_MISMATCH when value is a string' do + setup_flag('string-flag', 'not-a-number') + result = provider.fetch_float_value(flag_key: 'string-flag', default_value: 0.0) + expect(result.value).to eq(0.0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + end + + # --- Number evaluation --- + + describe '#fetch_number_value' do + it 'resolves an integer as number' do + setup_flag('num-flag', 42) + result = provider.fetch_number_value(flag_key: 'num-flag', default_value: 0) + expect(result.value).to eq(42) + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'resolves a float as number' do + setup_flag('num-flag', 3.14) + result = provider.fetch_number_value(flag_key: 'num-flag', default_value: 0.0) + expect(result.value).to be_within(0.001).of(3.14) + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'returns TYPE_MISMATCH when value is not numeric' do + setup_flag('string-flag', 'hello') + result = provider.fetch_number_value(flag_key: 'string-flag', default_value: 0) + expect(result.value).to eq(0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + end + + # --- Object evaluation --- + + describe '#fetch_object_value' do + it 'resolves a hash' do + setup_flag('obj-flag', { 'key' => 'value' }) + result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {}) + expect(result.value).to eq({ 'key' => 'value' }) + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'resolves an array' do + setup_flag('obj-flag', [1, 2, 3]) + result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: []) + expect(result.value).to eq([1, 2, 3]) + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'resolves a string as object' do + setup_flag('obj-flag', 'hello') + result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {}) + expect(result.value).to eq('hello') + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'resolves a boolean as object' do + setup_flag('obj-flag', true) + result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {}) + expect(result.value).to be true + expect(result.reason).to eq('TARGETING_MATCH') + end + end + + # --- FLAG_NOT_FOUND --- + + describe 'flag not found' do + before { setup_flag_not_found } + + it 'returns FLAG_NOT_FOUND for boolean' do + result = provider.fetch_boolean_value(flag_key: 'missing', default_value: true) + expect(result.value).to be true + expect(result.error_code).to eq('FLAG_NOT_FOUND') + expect(result.reason).to eq('DEFAULT') + end + + it 'returns FLAG_NOT_FOUND for string' do + result = provider.fetch_string_value(flag_key: 'missing', default_value: 'fallback') + expect(result.value).to eq('fallback') + expect(result.error_code).to eq('FLAG_NOT_FOUND') + expect(result.reason).to eq('DEFAULT') + end + + it 'returns FLAG_NOT_FOUND for integer' do + result = provider.fetch_integer_value(flag_key: 'missing', default_value: 99) + expect(result.value).to eq(99) + expect(result.error_code).to eq('FLAG_NOT_FOUND') + expect(result.reason).to eq('DEFAULT') + end + + it 'returns FLAG_NOT_FOUND for float' do + result = provider.fetch_float_value(flag_key: 'missing', default_value: 1.5) + expect(result.value).to eq(1.5) + expect(result.error_code).to eq('FLAG_NOT_FOUND') + expect(result.reason).to eq('DEFAULT') + end + + it 'returns FLAG_NOT_FOUND for object' do + result = provider.fetch_object_value(flag_key: 'missing', default_value: { 'default' => true }) + expect(result.value).to eq({ 'default' => true }) + expect(result.error_code).to eq('FLAG_NOT_FOUND') + expect(result.reason).to eq('DEFAULT') + end + end + + # --- PROVIDER_NOT_READY --- + + describe 'provider not ready' do + let(:mock_flags) do + instance_double('FlagsProvider').tap do |flags| + allow(flags).to receive(:are_flags_ready).and_return(false) + end + end + + it 'returns PROVIDER_NOT_READY for boolean' do + result = provider.fetch_boolean_value(flag_key: 'any', default_value: true) + expect(result.value).to be true + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + + it 'returns PROVIDER_NOT_READY for string' do + result = provider.fetch_string_value(flag_key: 'any', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + + it 'returns PROVIDER_NOT_READY for integer' do + result = provider.fetch_integer_value(flag_key: 'any', default_value: 5) + expect(result.value).to eq(5) + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + + it 'returns PROVIDER_NOT_READY for float' do + result = provider.fetch_float_value(flag_key: 'any', default_value: 2.5) + expect(result.value).to eq(2.5) + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + + it 'returns PROVIDER_NOT_READY for object' do + result = provider.fetch_object_value(flag_key: 'any', default_value: { 'default' => true }) + expect(result.value).to eq({ 'default' => true }) + expect(result.error_code).to eq('PROVIDER_NOT_READY') + expect(result.reason).to eq('ERROR') + end + end + + # --- Remote provider (no are_flags_ready) is always ready --- + + describe 'remote provider without are_flags_ready' do + let(:remote_flags) do + double('RemoteFlagsProvider').tap do |flags| + allow(flags).to receive(:get_variant) do |_key, _fallback, _ctx| + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + end + end + + let(:provider) { described_class.new(remote_flags) } + + it 'treats provider as ready' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + expect(result.value).to be true + expect(result.reason).to eq('TARGETING_MATCH') + end + end + + # --- Variant key passthrough --- + + describe 'variant key' do + it 'includes variant key in successful resolution' do + setup_flag('flag', 'value', variant_key: 'my-variant') + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.variant).to eq('my-variant') + expect(result.reason).to eq('TARGETING_MATCH') + end + + it 'does not include variant on error' do + setup_flag_not_found + result = provider.fetch_string_value(flag_key: 'missing', default_value: 'default') + expect(result.variant).to be_nil + end + end + + # --- Context forwarding --- + + describe 'evaluation context forwarding' do + it 'forwards evaluation_context fields to get_variant' do + eval_context = double('EvaluationContext', + fields: { 'distinct_id' => 'user-1', 'plan' => 'premium' }, + targeting_key: nil + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'distinct_id' => 'user-1', 'plan' => 'premium' }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'includes targeting_key in context when present' do + eval_context = double('EvaluationContext', + fields: { 'distinct_id' => 'user-1' }, + targeting_key: 'tk-123' + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'distinct_id' => 'user-1', 'targetingKey' => 'tk-123' }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'coerces whole floats to integers in context' do + eval_context = double('EvaluationContext', + fields: { 'distinct_id' => 'user-1', 'age' => 30.0 }, + targeting_key: nil + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'distinct_id' => 'user-1', 'age' => 30 }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'preserves fractional floats in context' do + eval_context = double('EvaluationContext', + fields: { 'score' => 3.14 }, + targeting_key: nil + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'score' => 3.14 }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'recursively unwraps arrays in context' do + eval_context = double('EvaluationContext', + fields: { 'tags' => ['a', 'b', 'c'] }, + targeting_key: nil + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'tags' => ['a', 'b', 'c'] }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'recursively unwraps nested hashes in context' do + eval_context = double('EvaluationContext', + fields: { 'meta' => { 'nested_float' => 5.0, 'name' => 'test' } }, + targeting_key: nil + ) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({ 'meta' => { 'nested_float' => 5, 'name' => 'test' } }) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context) + end + + it 'passes empty hash when evaluation_context is nil' do + allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| + expect(ctx).to eq({}) + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + + provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + end + end + + # --- SDK exception handling --- + + describe 'SDK exception handling' do + it 'returns default value with GENERAL error when get_variant raises' do + allow(mock_flags).to receive(:get_variant).and_raise(RuntimeError, 'unexpected SDK error') + + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) + expect(result.value).to be true + expect(result.error_code).to eq('GENERAL') + expect(result.reason).to eq('ERROR') + end + + it 'returns default value for string when get_variant raises' do + allow(mock_flags).to receive(:get_variant).and_raise(StandardError, 'connection failed') + + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'fallback') + expect(result.value).to eq('fallback') + expect(result.error_code).to eq('GENERAL') + expect(result.reason).to eq('ERROR') + end + + it 'returns default value for integer when get_variant raises' do + allow(mock_flags).to receive(:get_variant).and_raise(StandardError, 'timeout') + + result = provider.fetch_integer_value(flag_key: 'flag', default_value: 42) + expect(result.value).to eq(42) + expect(result.error_code).to eq('GENERAL') + expect(result.reason).to eq('ERROR') + end + end + + # --- Null variant key --- + + describe 'null variant key' do + it 'resolves successfully with nil variant when variant_key is nil' do + setup_flag('flag', 'hello', variant_key: nil) + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('hello') + expect(result.variant).to be_nil + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + + it 'resolves boolean with nil variant key' do + setup_flag('flag', true, variant_key: nil) + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + expect(result.value).to be true + expect(result.variant).to be_nil + expect(result.reason).to eq('TARGETING_MATCH') + end + end + + # --- Nil variant value --- + + describe 'nil variant value' do + it 'returns TYPE_MISMATCH for boolean when variant value is nil' do + setup_flag('nil-flag', nil) + result = provider.fetch_boolean_value(flag_key: 'nil-flag', default_value: false) + expect(result.value).to be false + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'returns TYPE_MISMATCH for string when variant value is nil' do + setup_flag('nil-flag', nil) + result = provider.fetch_string_value(flag_key: 'nil-flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'returns TYPE_MISMATCH for integer when variant value is nil' do + setup_flag('nil-flag', nil) + result = provider.fetch_integer_value(flag_key: 'nil-flag', default_value: 0) + expect(result.value).to eq(0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'returns TYPE_MISMATCH for float when variant value is nil' do + setup_flag('nil-flag', nil) + result = provider.fetch_float_value(flag_key: 'nil-flag', default_value: 0.0) + expect(result.value).to eq(0.0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'returns TYPE_MISMATCH for number when variant value is nil' do + setup_flag('nil-flag', nil) + result = provider.fetch_number_value(flag_key: 'nil-flag', default_value: 0) + expect(result.value).to eq(0) + expect(result.error_code).to eq('TYPE_MISMATCH') + expect(result.reason).to eq('ERROR') + end + + it 'allows nil variant value for object type' do + setup_flag('nil-flag', nil) + result = provider.fetch_object_value(flag_key: 'nil-flag', default_value: {}) + expect(result.value).to be_nil + expect(result.reason).to eq('TARGETING_MATCH') + expect(result.error_code).to be_nil + end + end + + # --- Exposure reporting --- + + describe 'exposure reporting' do + it 'calls get_variant with report_exposure: true' do + allow(mock_flags).to receive(:get_variant) do |_key, _fallback, _ctx, report_exposure:| + expect(report_exposure).to be true + Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) + end + provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + end + end + + # --- Lifecycle --- + + describe '#shutdown' do + it 'delegates to the flags provider' do + allow(mock_flags).to receive(:shutdown) + provider.shutdown + expect(mock_flags).to have_received(:shutdown) + end + end +end diff --git a/openfeature-provider/spec/spec_helper.rb b/openfeature-provider/spec/spec_helper.rb new file mode 100644 index 0000000..bfad3c5 --- /dev/null +++ b/openfeature-provider/spec/spec_helper.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'simplecov' +require 'simplecov-cobertura' + +SimpleCov.start do + add_filter '/spec/' + + formatter SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::CoberturaFormatter + ]) +end + +require 'mixpanel/openfeature' +require 'mixpanel-ruby/flags/types' + +RSpec.configure do |config| + config.run_all_when_everything_filtered = true + config.filter_run :focus + config.raise_errors_for_deprecations! + config.order = 'random' +end