From c6171401d8d8f5c3bf1308e65c16965742a62037 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 19 Mar 2026 11:44:05 -0700 Subject: [PATCH 01/17] Add OpenFeature provider for Mixpanel Ruby SDK Implement an OpenFeature-compatible provider as a separate gem (mixpanel-openfeature) that wraps the existing Mixpanel flags providers. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/Gemfile | 7 + .../lib/mixpanel/openfeature.rb | 3 + .../lib/mixpanel/openfeature/provider.rb | 129 ++++++ .../mixpanel-openfeature.gemspec | 21 + .../mixpanel_openfeature_provider_spec.rb | 375 ++++++++++++++++++ openfeature-provider/spec/spec_helper.rb | 11 + 6 files changed, 546 insertions(+) create mode 100644 openfeature-provider/Gemfile create mode 100644 openfeature-provider/lib/mixpanel/openfeature.rb create mode 100644 openfeature-provider/lib/mixpanel/openfeature/provider.rb create mode 100644 openfeature-provider/mixpanel-openfeature.gemspec create mode 100644 openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb create mode 100644 openfeature-provider/spec/spec_helper.rb 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/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..7f351f7 --- /dev/null +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'open_feature/sdk' + +module Mixpanel + module OpenFeature + class Provider + attr_reader :metadata + + def initialize(flags_provider) + @flags_provider = flags_provider + @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: 'mixpanel-provider').freeze + end + + def shutdown + # No-op — Mixpanel SDK manages its own lifecycle + 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) + result = @flags_provider.get_variant(flag_key, fallback, context) + + if result.equal?(fallback) + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + end + + value = result.variant_value + + 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::STATIC + ) + end + + def coerce_value(value, expected_type) + case expected_type + when :boolean + value.is_a?(TrueClass) || value.is_a?(FalseClass) ? 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 + if value.is_a?(Numeric) + value + end + 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] = v } + end + if evaluation_context.respond_to?(:targeting_key) && evaluation_context.targeting_key + ctx['targeting_key'] = evaluation_context.targeting_key + end + ctx + 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-openfeature.gemspec b/openfeature-provider/mixpanel-openfeature.gemspec new file mode 100644 index 0000000..17566cf --- /dev/null +++ b/openfeature-provider/mixpanel-openfeature.gemspec @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +Gem::Specification.new do |spec| + spec.name = 'mixpanel-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.0.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.0' + + spec.add_development_dependency 'rspec', '~> 3.0' +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..5289b14 --- /dev/null +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mixpanel::OpenFeature::Provider do + 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| + 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| 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('STATIC') + 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('ERROR') + 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('ERROR') + 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('ERROR') + 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('ERROR') + 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('ERROR') + 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('STATIC') + 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('STATIC') + 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', 'targeting_key' => '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 '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 + + # --- Lifecycle --- + + describe '#shutdown' do + it 'is a no-op' do + expect { provider.shutdown }.not_to raise_error + 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..48f692b --- /dev/null +++ b/openfeature-provider/spec/spec_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +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 From 6e3cac9c8df61d580b0314ac75521764d0e059e1 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Fri, 20 Mar 2026 11:05:10 -0700 Subject: [PATCH 02/17] Add missing OpenFeature provider tests and SDK exception handling Adds begin/rescue for SDK exceptions in provider, plus tests for exception handling and null variant key behavior. Co-Authored-By: Claude Opus 4.6 --- .../lib/mixpanel/openfeature/provider.rb | 7 ++- .../mixpanel_openfeature_provider_spec.rb | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 7f351f7..80ad092 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -49,7 +49,12 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) context = build_context(evaluation_context) fallback = ::Mixpanel::Flags::SelectedVariant.new(variant_value: default_value) - result = @flags_provider.get_variant(flag_key, fallback, context) + + begin + result = @flags_provider.get_variant(flag_key, fallback, context) + rescue StandardError + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL) + end if result.equal?(fallback) return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index 5289b14..005aadc 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -365,6 +365,58 @@ def setup_flag_not_found 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('STATIC') + 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('STATIC') + end + end + # --- Lifecycle --- describe '#shutdown' do From 879abb5341d28a7503b29fc11a5a311b344c6db2 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 23 Mar 2026 09:48:16 -0700 Subject: [PATCH 03/17] Fix targetingKey casing in OpenFeature context passthrough Use camelCase 'targetingKey' instead of snake_case 'targeting_key' to match the key name Java and Go SDKs receive from their OpenFeature SDKs' built-in flattening. Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/lib/mixpanel/openfeature/provider.rb | 2 +- openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 80ad092..fe320d2 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -109,7 +109,7 @@ def build_context(evaluation_context) evaluation_context.fields.each { |k, v| ctx[k] = v } end if evaluation_context.respond_to?(:targeting_key) && evaluation_context.targeting_key - ctx['targeting_key'] = evaluation_context.targeting_key + ctx['targetingKey'] = evaluation_context.targeting_key end ctx end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index 005aadc..0857212 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -348,7 +348,7 @@ def setup_flag_not_found targeting_key: 'tk-123' ) allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx| - expect(ctx).to eq({ 'distinct_id' => 'user-1', 'targeting_key' => 'tk-123' }) + expect(ctx).to eq({ 'distinct_id' => 'user-1', 'targetingKey' => 'tk-123' }) Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true) end From e8bf4599c442b40766547ef1727f5ba3fbe39ddc Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 23 Mar 2026 16:00:35 -0700 Subject: [PATCH 04/17] Align OpenFeature provider with server provider spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shutdown now delegates to underlying flags provider - Add shutdown to FlagsProvider (no-op) and LocalFlagsProvider (stops polling) - Explicitly pass report_exposure: true to get_variant - Add context value unwrapping with whole-number float→int conversion Co-Authored-By: Claude Opus 4.6 --- lib/mixpanel-ruby/flags/flags_provider.rb | 2 ++ .../flags/local_flags_provider.rb | 4 ++++ .../lib/mixpanel/openfeature/provider.rb | 21 +++++++++++++++---- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 3984b4c..0b5efa6 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -80,6 +80,8 @@ def call_flags_endpoint(additional_params = nil) # @param selected_variant [SelectedVariant] The selected variant # @param context [Hash] User context (must include 'distinct_id') # @param latency_ms [Integer, nil] Optional latency in milliseconds + def shutdown; end + def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil) distinct_id = context['distinct_id'] || context[:distinct_id] diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index e1f1ab2..209eb1a 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') diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index fe320d2..555df6f 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -13,7 +13,7 @@ def initialize(flags_provider) end def shutdown - # No-op — Mixpanel SDK manages its own lifecycle + @flags_provider.shutdown if @flags_provider.respond_to?(:shutdown) end def fetch_boolean_value(flag_key:, default_value:, evaluation_context: nil) @@ -51,7 +51,7 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) fallback = ::Mixpanel::Flags::SelectedVariant.new(variant_value: default_value) begin - result = @flags_provider.get_variant(flag_key, fallback, context) + 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 @@ -106,14 +106,27 @@ def build_context(evaluation_context) ctx = {} if evaluation_context.respond_to?(:fields) - evaluation_context.fields.each { |k, v| ctx[k] = v } + 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'] = 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 From e9f31a0119174cccd6dcc4fc6741068a9a990215 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 12:49:25 -0700 Subject: [PATCH 05/17] Fix error handling bug and simplify with idiomatic Ruby Fix bug where ServerError was incorrectly caught and re-raised as ConnectionError in call_flags_endpoint. Use dig for nested hash access, each_with_object for hash construction, map(&:dup), and simplified conditionals throughout. Simplify boolean type check and make number case consistent in provider. Co-Authored-By: Claude Opus 4.6 --- lib/mixpanel-ruby/flags/flags_provider.rb | 18 +++++---- .../flags/local_flags_provider.rb | 38 ++++++++----------- .../lib/mixpanel/openfeature/provider.rb | 6 +-- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 0b5efa6..695c7ab 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -56,22 +56,24 @@ 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 diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 209eb1a..a3cf7c8 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -111,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 @@ -151,13 +144,10 @@ 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 + @flag_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions| + definitions[flag_data['key']] = flag_data end - @flag_definitions = new_definitions - response end @@ -179,9 +169,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'], @@ -195,9 +186,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 @@ -228,7 +220,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/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 555df6f..18bcb93 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -77,7 +77,7 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) def coerce_value(value, expected_type) case expected_type when :boolean - value.is_a?(TrueClass) || value.is_a?(FalseClass) ? value : nil + value == true || value == false ? value : nil when :string value.is_a?(String) ? value : nil when :integer @@ -93,9 +93,7 @@ def coerce_value(value, expected_type) value.to_f end when :number - if value.is_a?(Numeric) - value - end + value.is_a?(Numeric) ? value : nil when :object value end From c2e009e99e396a27ea9856d91ef57de4d6c832db Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Wed, 1 Apr 2026 14:12:51 -0700 Subject: [PATCH 06/17] Fix nil variant bypass, improve test coverage and mock fidelity Add nil guard for typed resolutions to return TYPE_MISMATCH instead of passing nil through. Update mock signatures to capture report_exposure keyword arg. Add exposure reporting verification test. Strengthen shutdown test to verify delegation. Co-Authored-By: Claude Opus 4.6 --- .../lib/mixpanel/openfeature/provider.rb | 4 + .../mixpanel_openfeature_provider_spec.rb | 74 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 18bcb93..0923c33 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -62,6 +62,10 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) 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) diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index 0857212..811ab13 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -12,7 +12,7 @@ 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| + 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 @@ -22,7 +22,7 @@ def setup_flag(flag_key, value, variant_key: 'variant-key') end def setup_flag_not_found - allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx| fallback } + allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback } end # --- Metadata --- @@ -417,11 +417,77 @@ def setup_flag_not_found 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('STATIC') + 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 'is a no-op' do - expect { provider.shutdown }.not_to raise_error + 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 From 5b9d0f6e95ba17fd0ef7995c241f04417b101f28 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 15:13:14 -0700 Subject: [PATCH 07/17] Pin GitHub Actions to full commit SHAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required by repo policy — all actions must use full-length commit SHAs instead of version tags. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ruby.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c981835..40f0f69 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,7 @@ 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 From d4dd3ecab36dd6c102976def0d58fb0240e6e1bd Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 15:25:16 -0700 Subject: [PATCH 08/17] Rename gem from mixpanel-openfeature to mixpanel-ruby-openfeature Aligns with the convention used by other SDKs (e.g. mixpanel-java-openfeature-provider) where the language name is included in the package name. Co-Authored-By: Claude Opus 4.6 --- ...el-openfeature.gemspec => mixpanel-ruby-openfeature.gemspec} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename openfeature-provider/{mixpanel-openfeature.gemspec => mixpanel-ruby-openfeature.gemspec} (94%) diff --git a/openfeature-provider/mixpanel-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec similarity index 94% rename from openfeature-provider/mixpanel-openfeature.gemspec rename to openfeature-provider/mixpanel-ruby-openfeature.gemspec index 17566cf..4807bfc 100644 --- a/openfeature-provider/mixpanel-openfeature.gemspec +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -1,7 +1,7 @@ # frozen_string_literal: true Gem::Specification.new do |spec| - spec.name = 'mixpanel-openfeature' + spec.name = 'mixpanel-ruby-openfeature' spec.version = '0.1.0' spec.authors = ['Mixpanel'] spec.email = 'support@mixpanel.com' From 2b579beab97fc0d8289e720b38d9986ef7dbd3c8 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 15:34:07 -0700 Subject: [PATCH 09/17] Add OpenFeature provider tests to CI workflow Runs the openfeature-provider test suite as a separate job across all Ruby versions to ensure the provider is tested in CI. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ruby.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 40f0f69..5c94469 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -35,3 +35,26 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} slug: mixpanel/mixpanel-ruby + + test-openfeature: + + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: + - '3.0' + - '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 From a05866d211273e6780a1a3162905b304d6c576dc Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 19:44:28 -0700 Subject: [PATCH 10/17] Bump minimum Ruby to 3.1 for OpenFeature provider openfeature-sdk 0.5.1 requires Ruby >= 3.1, so Ruby 3.0 is no longer compatible. Update the gemspec and remove 3.0 from the CI matrix. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ruby.yml | 1 - openfeature-provider/mixpanel-ruby-openfeature.gemspec | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 5c94469..3aa5d63 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -42,7 +42,6 @@ jobs: strategy: matrix: ruby-version: - - '3.0' - '3.1' - '3.2' - '3.3' diff --git a/openfeature-provider/mixpanel-ruby-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec index 4807bfc..5f7af94 100644 --- a/openfeature-provider/mixpanel-ruby-openfeature.gemspec +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -9,7 +9,7 @@ Gem::Specification.new do |spec| 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.0.0' + spec.required_ruby_version = '>= 3.1.0' spec.files = Dir['lib/**/*.rb'] spec.require_paths = ['lib'] From c952e30d8c725ff7b3544f17670369f5c96b3b43 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 2 Apr 2026 19:55:37 -0700 Subject: [PATCH 11/17] Add coverage reporting for OpenFeature provider tests Add SimpleCov with Cobertura output to the openfeature-provider test suite and upload coverage from the test-openfeature CI job. This lets Codecov merge coverage from both test suites, fixing the patch/project coverage check failures. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ruby.yml | 7 +++++++ .../mixpanel-ruby-openfeature.gemspec | 2 ++ openfeature-provider/spec/spec_helper.rb | 12 ++++++++++++ 3 files changed, 21 insertions(+) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 3aa5d63..17ad7dd 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -57,3 +57,10 @@ jobs: 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/openfeature-provider/mixpanel-ruby-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec index 5f7af94..553c188 100644 --- a/openfeature-provider/mixpanel-ruby-openfeature.gemspec +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -18,4 +18,6 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.0' 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/spec_helper.rb b/openfeature-provider/spec/spec_helper.rb index 48f692b..bfad3c5 100644 --- a/openfeature-provider/spec/spec_helper.rb +++ b/openfeature-provider/spec/spec_helper.rb @@ -1,5 +1,17 @@ # 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' From 3838a1f9c8cd9d64df9a9bd46ea9b6edf7b1ac95 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Mon, 6 Apr 2026 14:01:17 -0700 Subject: [PATCH 12/17] Add simplified class methods for OpenFeature provider Provider.from_local and Provider.from_remote accept a token and config, create the Tracker internally, auto-start polling for local configs, and expose the tracker via the mixpanel attr_reader. Co-Authored-By: Claude Opus 4.6 --- .../lib/mixpanel/openfeature/provider.rb | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 0923c33..146d771 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -5,7 +5,24 @@ module Mixpanel module OpenFeature class Provider - attr_reader :metadata + attr_reader :metadata, :mixpanel + + def self.from_local(token, config) + tracker = ::Mixpanel::Tracker.new(token, 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) + tracker = ::Mixpanel::Tracker.new(token, 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 From dcaa2b623d1b16e59d1f726f4fe2c8353f9a1057 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Tue, 7 Apr 2026 11:09:28 -0700 Subject: [PATCH 13/17] Fix PR review comments and improve OpenFeature provider test coverage Move shutdown method above track_exposure_event doc comments to fix separated param annotations. Revert @flag_definitions assignment to use a local variable for GIL-safe concurrent reads. Add tests for from_local/from_remote factory methods and complex context type unwrapping (floats, arrays, nested hashes) to reach 100% coverage. Co-Authored-By: Claude Opus 4.6 --- lib/mixpanel-ruby/flags/flags_provider.rb | 4 +- .../flags/local_flags_provider.rb | 3 +- .../mixpanel_openfeature_provider_spec.rb | 89 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/lib/mixpanel-ruby/flags/flags_provider.rb b/lib/mixpanel-ruby/flags/flags_provider.rb index 695c7ab..116f011 100644 --- a/lib/mixpanel-ruby/flags/flags_provider.rb +++ b/lib/mixpanel-ruby/flags/flags_provider.rb @@ -77,13 +77,13 @@ def call_flags_endpoint(additional_params = nil) end end + def shutdown; end + # Track exposure event to Mixpanel # @param flag_key [String] Feature flag key # @param selected_variant [SelectedVariant] The selected variant # @param context [Hash] User context (must include 'distinct_id') # @param latency_ms [Integer, nil] Optional latency in milliseconds - def shutdown; end - def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil) distinct_id = context['distinct_id'] || context[:distinct_id] diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index a3cf7c8..9bf1bb0 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -144,9 +144,10 @@ def get_all_variants(context) def fetch_flag_definitions response = call_flags_endpoint - @flag_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions| + new_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions| definitions[flag_data['key']] = flag_data end + @flag_definitions = new_definitions response end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index 811ab13..fe23ac4 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -3,6 +3,43 @@ 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', 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 + 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', remote_flags_config: config) + expect(provider.mixpanel).to eq(mock_tracker) + expect(provider).to be_a(described_class) + end + end + + # --- Instance behavior --- + let(:mock_flags) do instance_double('FlagsProvider').tap do |flags| allow(flags).to receive(:are_flags_ready).and_return(true) @@ -355,6 +392,58 @@ def setup_flag_not_found 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({}) From 676c8585df9f00f2a190b1e034804d5ea6d97282 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Tue, 7 Apr 2026 11:54:12 -0700 Subject: [PATCH 14/17] Use TARGETING_MATCH for successful resolutions, DEFAULT for flag not found Address review feedback: successful flag evaluations now return TARGETING_MATCH reason instead of STATIC, and FLAG_NOT_FOUND returns DEFAULT reason instead of ERROR. Also adds error_handler parameter to from_local and from_remote factory methods. Co-Authored-By: Claude Opus 4.6 --- .../lib/mixpanel/openfeature/provider.rb | 16 ++-- .../mixpanel_openfeature_provider_spec.rb | 74 ++++++++++++------- 2 files changed, 59 insertions(+), 31 deletions(-) diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 146d771..38aa349 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -7,8 +7,8 @@ module OpenFeature class Provider attr_reader :metadata, :mixpanel - def self.from_local(token, config) - tracker = ::Mixpanel::Tracker.new(token, local_flags_config: config) + 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) @@ -16,8 +16,8 @@ def self.from_local(token, config) provider end - def self.from_remote(token, config) - tracker = ::Mixpanel::Tracker.new(token, remote_flags_config: config) + 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) @@ -74,7 +74,11 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) end if result.equal?(fallback) - return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND) + 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 @@ -91,7 +95,7 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: coerced.nil? ? value : coerced, variant: result.variant_key, - reason: ::OpenFeature::SDK::Provider::Reason::STATIC + reason: ::OpenFeature::SDK::Provider::Reason::TARGETING_MATCH ) end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index fe23ac4..ea7bb24 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -16,11 +16,24 @@ config = { polling_interval: 300 } provider = described_class.from_local('test-token', config) - expect(Mixpanel::Tracker).to have_received(:new).with('test-token', local_flags_config: 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 @@ -32,10 +45,21 @@ config = { endpoint: 'https://example.com' } provider = described_class.from_remote('test-token', config) - expect(Mixpanel::Tracker).to have_received(:new).with('test-token', remote_flags_config: 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 --- @@ -77,7 +101,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -85,7 +109,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end it 'returns TYPE_MISMATCH when value is not boolean' do @@ -104,7 +128,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -124,7 +148,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -133,7 +157,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end it 'returns TYPE_MISMATCH for float with fraction' do @@ -160,7 +184,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -169,7 +193,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end it 'returns TYPE_MISMATCH when value is a string' do @@ -188,14 +212,14 @@ def setup_flag_not_found 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('STATIC') + 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end it 'returns TYPE_MISMATCH when value is not numeric' do @@ -214,7 +238,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -222,21 +246,21 @@ def setup_flag_not_found 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('STATIC') + 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('STATIC') + 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end end @@ -249,35 +273,35 @@ def setup_flag_not_found 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('ERROR') + 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('ERROR') + 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('ERROR') + 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('ERROR') + 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('ERROR') + expect(result.reason).to eq('DEFAULT') end end @@ -342,7 +366,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end end @@ -353,7 +377,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end it 'does not include variant on error' do @@ -493,7 +517,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end @@ -502,7 +526,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') end end @@ -553,7 +577,7 @@ def setup_flag_not_found 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('STATIC') + expect(result.reason).to eq('TARGETING_MATCH') expect(result.error_code).to be_nil end end From 51b3a1d95788a76b575d6a901846e55eeed21807 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 14:16:40 -0700 Subject: [PATCH 15/17] Add README for OpenFeature provider Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/README.md | 286 +++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 openfeature-provider/README.md 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 From 020f4ed43ddc02b11e872c8906785059f804441c Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 14:52:17 -0700 Subject: [PATCH 16/17] Add release documentation for OpenFeature provider Co-Authored-By: Claude Opus 4.6 --- openfeature-provider/RELEASE.md | 42 +++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 openfeature-provider/RELEASE.md diff --git a/openfeature-provider/RELEASE.md b/openfeature-provider/RELEASE.md new file mode 100644 index 0000000..d989cb8 --- /dev/null +++ b/openfeature-provider/RELEASE.md @@ -0,0 +1,42 @@ +# Releasing the OpenFeature Provider + +The OpenFeature provider (`mixpanel-ruby-openfeature`) is published to RubyGems independently from the core SDK. + +## 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. From a4c48c923aff9e8b968e0d3cf600202c4c35b588 Mon Sep 17 00:00:00 2001 From: Mark Siebert Date: Thu, 9 Apr 2026 15:08:21 -0700 Subject: [PATCH 17/17] Bump SDK to 3.1.0 and require it for OpenFeature provider The OpenFeature provider depends on shutdown methods and error handling fixes added after the 3.0.0 release. Bump the main SDK version and tighten the provider's dependency constraint to ~> 3.1. Add release ordering documentation. Co-Authored-By: Claude Opus 4.6 --- lib/mixpanel-ruby/version.rb | 2 +- openfeature-provider/RELEASE.md | 10 ++++++++++ openfeature-provider/mixpanel-ruby-openfeature.gemspec | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) 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/RELEASE.md b/openfeature-provider/RELEASE.md index d989cb8..cab74f9 100644 --- a/openfeature-provider/RELEASE.md +++ b/openfeature-provider/RELEASE.md @@ -2,6 +2,16 @@ 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+ diff --git a/openfeature-provider/mixpanel-ruby-openfeature.gemspec b/openfeature-provider/mixpanel-ruby-openfeature.gemspec index 553c188..9a496f3 100644 --- a/openfeature-provider/mixpanel-ruby-openfeature.gemspec +++ b/openfeature-provider/mixpanel-ruby-openfeature.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.add_runtime_dependency 'openfeature-sdk', '~> 0.5' - spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.0' + spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.1' spec.add_development_dependency 'rspec', '~> 3.0' spec.add_development_dependency 'simplecov'