diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index 9bf1bb0..1506ccd 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -102,11 +102,11 @@ def get_variant_value(flag_key, fallback_value, context, report_exposure: true) def get_variant(flag_key, fallback_variant, context, report_exposure: true) flag = @flag_definitions[flag_key] - return fallback_variant unless flag + return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless flag context_key = flag['context'] unless context.key?(context_key) || context.key?(context_key.to_sym) - return fallback_variant + return fallback_variant.as_fallback(FallbackReason.missing_context_key(context_key)) end context_value = context[context_key] || context[context_key.to_sym] @@ -118,10 +118,10 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout end - return fallback_variant unless selected_variant + return fallback_variant.as_fallback(FallbackReason.no_rollout_match) unless selected_variant track_exposure_event(flag_key, selected_variant, context) if report_exposure - selected_variant + selected_variant.with_source(VariantSource::LOCAL) end # Get all variants for user context @@ -130,10 +130,11 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) # @return [Hash] Map of flag_key => SelectedVariant def get_all_variants(context) variants = {} + fallback = SelectedVariant.new(variant_value: nil) @flag_definitions.each_key do |flag_key| - variant = get_variant(flag_key, nil, context, report_exposure: false) - variants[flag_key] = variant if variant + variant = get_variant(flag_key, fallback, context, report_exposure: false) + variants[flag_key] = variant if variant.variant_source == VariantSource::LOCAL end variants diff --git a/lib/mixpanel-ruby/flags/remote_flags_provider.rb b/lib/mixpanel-ruby/flags/remote_flags_provider.rb index eaa6471..84187b3 100644 --- a/lib/mixpanel-ruby/flags/remote_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/remote_flags_provider.rb @@ -59,13 +59,18 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) flags = response['flags'] || {} selected_variant_data = flags[flag_key] - return fallback_variant unless selected_variant_data + # The /flags endpoint only returns variants the user is enrolled in, + # so a missing key could mean the flag doesn't exist OR the user + # isn't in any rollout. The remote SDK can't tell them apart without + # server-side help — surface as FLAG_NOT_FOUND for now. + return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless selected_variant_data selected_variant = SelectedVariant.new( variant_key: selected_variant_data['variant_key'], variant_value: selected_variant_data['variant_value'], experiment_id: selected_variant_data['experiment_id'], - is_experiment_active: selected_variant_data['is_experiment_active'] + is_experiment_active: selected_variant_data['is_experiment_active'], + variant_source: VariantSource::REMOTE ) track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure @@ -73,7 +78,12 @@ def get_variant(flag_key, fallback_variant, context, report_exposure: true) return selected_variant rescue MixpanelError => e @error_handler.handle(e) - return fallback_variant + # Attach the backend's message so the OpenFeature wrapper can forward + # it into ResolutionDetails#error_message — without this the caller + # sees a bare GENERAL error and has to dig through logs to find out + # the backend rejected the request (e.g. "distinct_id must be + # provided in evalContext as a string"). SDK-83. + return fallback_variant.as_fallback(FallbackReason.backend_error(e.message)) end # Check if flag is enabled (for boolean flags) @@ -104,7 +114,8 @@ def get_all_variants(context) variant_key: variant_data['variant_key'], variant_value: variant_data['variant_value'], experiment_id: variant_data['experiment_id'], - is_experiment_active: variant_data['is_experiment_active'] + is_experiment_active: variant_data['is_experiment_active'], + variant_source: VariantSource::REMOTE ) end diff --git a/lib/mixpanel-ruby/flags/types.rb b/lib/mixpanel-ruby/flags/types.rb index f996804..c84ad96 100644 --- a/lib/mixpanel-ruby/flags/types.rb +++ b/lib/mixpanel-ruby/flags/types.rb @@ -1,35 +1,114 @@ module Mixpanel module Flags + # Where a SelectedVariant came from. Set by the providers on every returned + # variant — coarse-grained (local / remote / fallback). For the specific + # reason behind a fallback, see {FallbackReason}. + module VariantSource + LOCAL = 'local'.freeze + REMOTE = 'remote'.freeze + FALLBACK = 'fallback'.freeze + end + + # Why the SDK returned the developer fallback. Only meaningful when + # SelectedVariant#variant_source == VariantSource::FALLBACK. + # + # `kind` is the discriminator (matches the PHP constant set). `message` + # is set on the reasons that carry useful detail (BACKEND_ERROR with the + # backend's response, MISSING_CONTEXT_KEY with the missing attribute); + # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards + # message into ResolutionDetails#error_message. + class FallbackReason + KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze + + attr_reader :kind, :message + + def initialize(kind, message: nil) + raise ArgumentError, "Unknown FallbackReason kind: #{kind.inspect}" unless KINDS.include?(kind) + + @kind = kind + @message = message + freeze + end + + def ==(other) + other.is_a?(FallbackReason) && other.kind == @kind && other.message == @message + end + alias_method :eql?, :== + + def hash + [self.class, @kind, @message].hash + end + + def to_h + { kind: @kind, message: @message }.compact + end + + # Factory methods. Reasons without meaningful detail return a frozen + # singleton; reasons with detail allocate per call. + def self.flag_not_found; FLAG_NOT_FOUND; end + def self.no_rollout_match; NO_ROLLOUT_MATCH; end + def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end + def self.backend_error(message); new(:backend_error, message: message); end + + FLAG_NOT_FOUND = new(:flag_not_found) + NO_ROLLOUT_MATCH = new(:no_rollout_match) + end + # Selected variant returned from flag evaluation class SelectedVariant attr_accessor :variant_key, :variant_value, :experiment_id, - :is_experiment_active, :is_qa_tester + :is_experiment_active, :is_qa_tester, + :variant_source, :fallback_reason - # @param variant_key [String, nil] The variant key - # @param variant_value [Object] The variant value (any type) - # @param experiment_id [String, nil] Associated experiment ID - # @param is_experiment_active [Boolean, nil] Whether experiment is active - # @param is_qa_tester [Boolean, nil] Whether user is a QA tester def initialize(variant_key: nil, variant_value: nil, experiment_id: nil, - is_experiment_active: nil, is_qa_tester: nil) + is_experiment_active: nil, is_qa_tester: nil, + variant_source: nil, fallback_reason: nil) @variant_key = variant_key @variant_value = variant_value @experiment_id = experiment_id @is_experiment_active = is_experiment_active @is_qa_tester = is_qa_tester + @variant_source = variant_source + @fallback_reason = fallback_reason + end + + # Return a copy of this variant tagged with the given source. Clears + # fallback_reason — use {#as_fallback} when returning a fallback. + def with_source(source) + copy_with(variant_source: source, fallback_reason: nil) + end + + # Return a copy tagged as a fallback with the given reason. + def as_fallback(reason) + copy_with(variant_source: VariantSource::FALLBACK, fallback_reason: reason) end # Convert to hash representation - # @return [Hash] def to_h { variant_key: @variant_key, variant_value: @variant_value, experiment_id: @experiment_id, is_experiment_active: @is_experiment_active, - is_qa_tester: @is_qa_tester + is_qa_tester: @is_qa_tester, + variant_source: @variant_source, + fallback_reason: @fallback_reason }.compact end + + private + + def copy_with(variant_source: @variant_source, fallback_reason: @fallback_reason) + SelectedVariant.new( + variant_key: @variant_key, + variant_value: @variant_value, + experiment_id: @experiment_id, + is_experiment_active: @is_experiment_active, + is_qa_tester: @is_qa_tester, + variant_source: variant_source, + fallback_reason: fallback_reason + ) + end end end end diff --git a/openfeature-provider/lib/mixpanel/openfeature/provider.rb b/openfeature-provider/lib/mixpanel/openfeature/provider.rb index 38aa349..6014d5e 100644 --- a/openfeature-provider/lib/mixpanel/openfeature/provider.rb +++ b/openfeature-provider/lib/mixpanel/openfeature/provider.rb @@ -69,16 +69,41 @@ def resolve(flag_key, default_value, expected_type, evaluation_context) 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) + rescue StandardError => e + return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message) end - if result.equal?(fallback) + # variant_source distinguishes local / remote / fallback. When fallback, + # fallback_reason carries the specific reason (PHP-aligned constants) + # so we can map each to the spec-correct OpenFeature response instead + # of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR + # also carries the backend message, forwarded as error_message. + case result.fallback_reason&.kind + when :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 ) + when :missing_context_key + return error_result( + default_value, + ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING, + result.fallback_reason.message + ) + when :no_rollout_match + # Flag exists, user just didn't match any rollout — per the + # OpenFeature spec this is `reason: DEFAULT` with no error. + return ::OpenFeature::SDK::Provider::ResolutionDetails.new( + value: default_value, + reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT + ) + when :backend_error + return error_result( + default_value, + ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, + result.fallback_reason.message + ) end value = result.variant_value @@ -158,10 +183,11 @@ def flags_ready? end end - def error_result(default_value, error_code) + def error_result(default_value, error_code, error_message = nil) ::OpenFeature::SDK::Provider::ResolutionDetails.new( value: default_value, error_code: error_code, + error_message: error_message, reason: ::OpenFeature::SDK::Provider::Reason::ERROR ) end diff --git a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb index ea7bb24..6070bdc 100644 --- a/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +++ b/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb @@ -75,15 +75,25 @@ 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) + Mixpanel::Flags::SelectedVariant.new( + variant_key: variant_key, + variant_value: value, + variant_source: Mixpanel::Flags::VariantSource::LOCAL + ) else - fallback + fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found) end end end + def setup_fallback(reason) + allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs| + fallback.as_fallback(reason) + end + end + def setup_flag_not_found - allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback } + setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found) end # --- Metadata --- @@ -305,6 +315,67 @@ def setup_flag_not_found end end + # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) --- + + describe 'no rollout match' do + before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) } + + it 'returns DEFAULT reason without an error code' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true) + expect(result.value).to be true + expect(result.reason).to eq('DEFAULT') + expect(result.error_code).to be_nil + end + + it 'works for strings' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.reason).to eq('DEFAULT') + expect(result.error_code).to be_nil + end + end + + # --- MISSING_CONTEXT_KEY --- + + describe 'missing context key' do + before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) } + + it 'returns TARGETING_KEY_MISSING for boolean' do + result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false) + expect(result.value).to be false + expect(result.error_code).to eq('TARGETING_KEY_MISSING') + expect(result.reason).to eq('ERROR') + end + + it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('TARGETING_KEY_MISSING') + expect(result.error_message).to eq('distinct_id') + expect(result.reason).to eq('ERROR') + end + end + + # --- BACKEND_ERROR (SDK-83: forwards the backend's response message) --- + + describe 'backend error' do + before do + setup_fallback( + Mixpanel::Flags::FallbackReason.backend_error( + 'HTTP 400: distinct_id must be provided in evalContext as a string' + ) + ) + end + + it 'maps to GENERAL and forwards the backend message as error_message' do + result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default') + expect(result.value).to eq('default') + expect(result.error_code).to eq('GENERAL') + expect(result.reason).to eq('ERROR') + expect(result.error_message).to include('distinct_id must be provided') + end + end + # --- PROVIDER_NOT_READY --- describe 'provider not ready' do diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 6969521..0b6e391 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -720,6 +720,53 @@ def user_context_with_properties(properties) end end + describe '#get_variant variant_source / fallback_reason tagging' do + let(:fallback) { Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') } + + it 'tags matched variants as LOCAL with no fallback_reason' do + flag = create_test_flag(rollout_percentage: 100.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::LOCAL) + expect(result.fallback_reason).to be_nil + expect(result.variant_key).not_to be_nil + end + + it 'tags missing flag as FALLBACK / flag_not_found' do + stub_flag_definitions([]) + provider.start_polling_for_definitions! + + result = provider.get_variant('missing', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason.kind).to eq(:flag_not_found) + expect(result.fallback_reason.message).to be_nil + expect(result.variant_value).to eq('fb') + end + + it 'tags missing context as FALLBACK / missing_context_key with the missing attribute' do + flag = create_test_flag(context: 'distinct_id') + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, {}) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason.kind).to eq(:missing_context_key) + expect(result.fallback_reason.message).to eq('distinct_id') + end + + it 'tags no-rollout-match as FALLBACK / no_rollout_match' do + flag = create_test_flag(rollout_percentage: 0.0) + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + result = provider.get_variant('test_flag', fallback, test_context) + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason.kind).to eq(:no_rollout_match) + end + end + describe 'polling' do it 'uses most recent polled flag definitions' do flag_v1 = create_test_flag(rollout_percentage: 0.0) diff --git a/spec/mixpanel-ruby/flags/remote_flags_spec.rb b/spec/mixpanel-ruby/flags/remote_flags_spec.rb index 8e7308b..0413eea 100644 --- a/spec/mixpanel-ruby/flags/remote_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/remote_flags_spec.rb @@ -257,6 +257,18 @@ def stub_flags_request_error(error) provider.get_variant('any-flag', fallback_variant, test_context) end + + it 'tags the fallback as BACKEND_ERROR with the response body on HTTP error (SDK-83)' do + stub_request(:get, %r{https://api\.mixpanel\.com/flags}) + .to_return(status: 400, body: 'distinct_id must be provided in evalContext as a string') + + fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') + result = provider.get_variant('any-flag', fallback_variant, test_context, report_exposure: false) + + expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK) + expect(result.fallback_reason.kind).to eq(:backend_error) + expect(result.fallback_reason.message).to include('distinct_id must be provided') + end end describe '#is_enabled' do