Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions lib/mixpanel-ruby/flags/local_flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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
Expand Down
19 changes: 15 additions & 4 deletions lib/mixpanel-ruby/flags/remote_flags_provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,31 @@ 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

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)
Expand Down Expand Up @@ -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

Expand Down
97 changes: 88 additions & 9 deletions lib/mixpanel-ruby/flags/types.rb
Original file line number Diff line number Diff line change
@@ -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
34 changes: 30 additions & 4 deletions openfeature-provider/lib/mixpanel/openfeature/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
77 changes: 74 additions & 3 deletions openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading