mixpanel-ruby 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e8271da642afa8ee7d980b2508db17f84f342f33405bde9dd6d0a15b8a10df0
4
- data.tar.gz: 2d2da06b34ee942c35286b155bc5942a3ee56d8f17727063ebec16e5eb8d2149
3
+ metadata.gz: 1be60e96a1c17bd58aa1e6a1b9c17228a2b4934966c95bd893efa66d6a4a4f54
4
+ data.tar.gz: 8081213f4003791117e588c7afa479d249dd79ec219e16f109dda5ef26a0224c
5
5
  SHA512:
6
- metadata.gz: 0f8236551ad44db0c9b3afdcefa17083bbca02ff00b6b9a236123857eaf38595070b258c923671b0f0672c76aa6baadefc7d169e02f27ec33cb475b4b9eabb70
7
- data.tar.gz: 469d8373977e082acc887a8e11d8eeb973190a3488f40d5bb6fa66f4be7cd5c6e83772923e3ee291641f7aa25d8b0dd6d6123541f2e2ed90677c3b34f0e48bba
6
+ metadata.gz: f11a900846a56ab4d2ff879c23af112a086bee115e0b12aae4f5fb65ba9f37f6bb65959554e48da90d632e2e5a24070ed4e1867ef213db970d990caa2adc25c5
7
+ data.tar.gz: c7f2e19753de2dc9d55dcf93a798074ff1dc4cc82a5b2b33ce78dd765a33e98f6fc357d994580b38ac1ea2a92a9b942287f92ed3b34a4262f9bc75f476adc496
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.3.0) (2026-07-24)
4
+
5
+ ### Fixes
6
+ - allow capability to offload reportExposure to async thread (SDK-80) ([#157](https://github.com/mixpanel/mixpanel-ruby/pull/157))
7
+ - surface dropped exposure when distinct_id missing from context ([#154](https://github.com/mixpanel/mixpanel-ruby/pull/154))
8
+ - distinguish fallback reasons + forward backend error message (SDK-79, SDK-83) ([#153](https://github.com/mixpanel/mixpanel-ruby/pull/153))
9
+
10
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/v3.2.0...v3.3.0)
11
+
3
12
  ## [v3.2.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.2.0) (2026-07-10)
4
13
 
5
14
  ### Features
data/Readme.rdoc CHANGED
@@ -1,6 +1,6 @@
1
1
  = mixpanel-ruby: The official Mixpanel Ruby library
2
2
 
3
- ##### _July 10, 2026_ - [v3.2.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.2.0)
3
+ ##### _July 24, 2026_ - [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.3.0)
4
4
 
5
5
  mixpanel-ruby is a library for tracking events and sending \Mixpanel profile
6
6
  updates to \Mixpanel from your ruby applications.
@@ -12,7 +12,7 @@ module Mixpanel
12
12
  # Base class for feature flags providers
13
13
  # Provides common HTTP handling and exposure event tracking
14
14
  class FlagsProvider
15
- # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :credentials (optional)
15
+ # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds, :exposure_executor, :credentials (optional)
16
16
  # @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
17
17
  # @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
18
18
  # @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
@@ -23,6 +23,7 @@ module Mixpanel
23
23
  @tracker_callback = tracker_callback
24
24
  @evaluation_mode = evaluation_mode
25
25
  @error_handler = error_handler
26
+ @exposure_executor = provider_config[:exposure_executor]
26
27
  @credentials = provider_config[:credentials]
27
28
  end
28
29
 
@@ -102,6 +103,14 @@ module Mixpanel
102
103
  distinct_id = context['distinct_id'] || context[:distinct_id]
103
104
 
104
105
  unless distinct_id
106
+ # Local eval succeeds when the flag's Variant Assignment Key is
107
+ # something other than distinct_id (e.g., device_id), but the
108
+ # exposure event still needs distinct_id to attribute the user.
109
+ # Surface the drop instead of silently returning so callers can
110
+ # see they need to include distinct_id in the context.
111
+ @error_handler&.handle(MixpanelError.new(
112
+ "Cannot track exposure event for flag '#{flag_key}' without a distinct_id in the context"
113
+ ))
105
114
  return
106
115
  end
107
116
 
@@ -118,12 +127,40 @@ module Mixpanel
118
127
  properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
119
128
  properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
120
129
 
121
- begin
122
- @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
123
- rescue MixpanelError => e
124
- @error_handler.handle(e)
130
+ dispatch_exposure(distinct_id, properties)
131
+ end
132
+
133
+ private
134
+
135
+ # Dispatch the tracker call inline or via the configured executor.
136
+ # The executor is duck-typed — anything that responds to #post(&block)
137
+ # works (Concurrent::ExecutorService, or a Thread.new wrapper).
138
+ #
139
+ # Async path only: catch any non-MixpanelError from the tracker so it
140
+ # doesn't terminate the executor thread silently. Inline path
141
+ # preserves the original behavior — non-MixpanelError propagates to
142
+ # the flag evaluator's caller unchanged.
143
+ def dispatch_exposure(distinct_id, properties)
144
+ if @exposure_executor
145
+ begin
146
+ @exposure_executor.post do
147
+ invoke_tracker(distinct_id, properties)
148
+ rescue StandardError => e
149
+ @error_handler.handle(MixpanelError.new("Exposure event failed: #{e.class}: #{e.message}")) if @error_handler
150
+ end
151
+ rescue StandardError => e
152
+ @error_handler.handle(MixpanelError.new("Exposure event dropped — executor refused to accept task: #{e.message}")) if @error_handler
153
+ end
154
+ else
155
+ invoke_tracker(distinct_id, properties)
125
156
  end
126
157
  end
158
+
159
+ def invoke_tracker(distinct_id, properties)
160
+ @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
161
+ rescue MixpanelError => e
162
+ @error_handler.handle(e) if @error_handler
163
+ end
127
164
  end
128
165
  end
129
166
  end
@@ -11,7 +11,8 @@ module Mixpanel
11
11
  api_host: 'api.mixpanel.com',
12
12
  request_timeout_in_seconds: 10,
13
13
  enable_polling: true,
14
- polling_interval_in_seconds: 60
14
+ polling_interval_in_seconds: 60,
15
+ exposure_executor: nil
15
16
  }.freeze
16
17
 
17
18
  # @param token [String] Mixpanel project token
@@ -37,6 +38,7 @@ module Mixpanel
37
38
  token: token,
38
39
  api_host: @config[:api_host],
39
40
  request_timeout_in_seconds: @config[:request_timeout_in_seconds],
41
+ exposure_executor: @config[:exposure_executor],
40
42
  credentials: credentials
41
43
  }
42
44
 
@@ -71,7 +73,17 @@ module Mixpanel
71
73
  # blocking HTTP call, and holding the lifecycle lock across it would
72
74
  # block a concurrent stop_polling_for_definitions! for the full request
73
75
  # timeout.
74
- fetch_flag_definitions
76
+ #
77
+ # A transient failure here (network blip, HTTP 500) should NOT prevent
78
+ # the polling thread from spawning — the loop retries on the configured
79
+ # interval. Without this inner rescue, the outer rescue below would
80
+ # catch and return, leaving the SDK permanently without polling until
81
+ # the caller manually retried.
82
+ begin
83
+ fetch_flag_definitions
84
+ rescue StandardError => e
85
+ safe_handle_error(e)
86
+ end
75
87
 
76
88
  @lifecycle_mutex.synchronize do
77
89
  # If a stop arrived during/after our @stop_polling clear above, abort
@@ -159,11 +171,11 @@ module Mixpanel
159
171
  def get_variant(flag_key, fallback_variant, context, report_exposure: true)
160
172
  flag = @flag_definitions[flag_key]
161
173
 
162
- return fallback_variant unless flag
174
+ return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless flag
163
175
 
164
176
  context_key = flag['context']
165
177
  unless context.key?(context_key) || context.key?(context_key.to_sym)
166
- return fallback_variant
178
+ return fallback_variant.as_fallback(FallbackReason.missing_context_key(context_key))
167
179
  end
168
180
 
169
181
  context_value = context[context_key] || context[context_key.to_sym]
@@ -175,10 +187,10 @@ module Mixpanel
175
187
  selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
176
188
  end
177
189
 
178
- return fallback_variant unless selected_variant
190
+ return fallback_variant.as_fallback(FallbackReason.no_rollout_match) unless selected_variant
179
191
 
180
192
  track_exposure_event(flag_key, selected_variant, context) if report_exposure
181
- selected_variant
193
+ selected_variant.with_source(VariantSource::LOCAL)
182
194
  end
183
195
 
184
196
  # Get all variants for user context
@@ -187,10 +199,11 @@ module Mixpanel
187
199
  # @return [Hash] Map of flag_key => SelectedVariant
188
200
  def get_all_variants(context)
189
201
  variants = {}
202
+ fallback = SelectedVariant.new(variant_value: nil)
190
203
 
191
204
  @flag_definitions.each_key do |flag_key|
192
- variant = get_variant(flag_key, nil, context, report_exposure: false)
193
- variants[flag_key] = variant if variant
205
+ variant = get_variant(flag_key, fallback, context, report_exposure: false)
206
+ variants[flag_key] = variant if variant.variant_source == VariantSource::LOCAL
194
207
  end
195
208
 
196
209
  variants
@@ -201,11 +214,19 @@ module Mixpanel
201
214
  # Wrap @error_handler.handle so a misbehaving handler can't kill the
202
215
  # polling thread mid-loop — that would leave @polling_thread non-nil but
203
216
  # dead, and (without the .alive? check in start) silently prevent restart.
217
+ #
218
+ # Always warn to stderr as well. The default Mixpanel::ErrorHandler#handle
219
+ # is a no-op, so dispatching only via @error_handler swallows schema drift
220
+ # (NoMethodError, JSON::ParserError, etc.) — the loop runs forever
221
+ # undetected. Matches the convention in mixpanel-python / mixpanel-java /
222
+ # mixpanel-go / mixpanel-node, all of which log unconditionally and keep
223
+ # polling.
204
224
  def safe_handle_error(error)
225
+ warn "[Mixpanel] Failed to fetch flag definitions: #{error.class}: #{error.message}"
205
226
  @error_handler.handle(error) if @error_handler
206
227
  rescue StandardError
207
- # Swallow: keeping the polling loop alive is more important than
208
- # propagating a broken handler's failure.
228
+ # Swallow handler failures: keeping the polling loop alive is more
229
+ # important than propagating a broken handler's failure.
209
230
  end
210
231
 
211
232
  def fetch_flag_definitions
@@ -7,7 +7,8 @@ module Mixpanel
7
7
  class RemoteFlagsProvider < FlagsProvider
8
8
  DEFAULT_CONFIG = {
9
9
  api_host: 'api.mixpanel.com',
10
- request_timeout_in_seconds: 10
10
+ request_timeout_in_seconds: 10,
11
+ exposure_executor: nil
11
12
  }.freeze
12
13
 
13
14
  # @param token [String] Mixpanel project token
@@ -22,6 +23,7 @@ module Mixpanel
22
23
  token: token,
23
24
  api_host: merged_config[:api_host],
24
25
  request_timeout_in_seconds: merged_config[:request_timeout_in_seconds],
26
+ exposure_executor: merged_config[:exposure_executor],
25
27
  credentials: credentials
26
28
  }
27
29
 
@@ -61,13 +63,18 @@ module Mixpanel
61
63
  flags = response['flags'] || {}
62
64
  selected_variant_data = flags[flag_key]
63
65
 
64
- return fallback_variant unless selected_variant_data
66
+ # The /flags endpoint only returns variants the user is enrolled in,
67
+ # so a missing key could mean the flag doesn't exist OR the user
68
+ # isn't in any rollout. The remote SDK can't tell them apart without
69
+ # server-side help — surface as FLAG_NOT_FOUND for now.
70
+ return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless selected_variant_data
65
71
 
66
72
  selected_variant = SelectedVariant.new(
67
73
  variant_key: selected_variant_data['variant_key'],
68
74
  variant_value: selected_variant_data['variant_value'],
69
75
  experiment_id: selected_variant_data['experiment_id'],
70
- is_experiment_active: selected_variant_data['is_experiment_active']
76
+ is_experiment_active: selected_variant_data['is_experiment_active'],
77
+ variant_source: VariantSource::REMOTE
71
78
  )
72
79
 
73
80
  track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure
@@ -75,7 +82,12 @@ module Mixpanel
75
82
  return selected_variant
76
83
  rescue MixpanelError => e
77
84
  @error_handler.handle(e)
78
- return fallback_variant
85
+ # Attach the backend's message so the OpenFeature wrapper can forward
86
+ # it into ResolutionDetails#error_message — without this the caller
87
+ # sees a bare GENERAL error and has to dig through logs to find out
88
+ # the backend rejected the request (e.g. "distinct_id must be
89
+ # provided in evalContext as a string"). SDK-83.
90
+ return fallback_variant.as_fallback(FallbackReason.backend_error(e.message))
79
91
  end
80
92
 
81
93
  # Check if flag is enabled (for boolean flags)
@@ -106,7 +118,8 @@ module Mixpanel
106
118
  variant_key: variant_data['variant_key'],
107
119
  variant_value: variant_data['variant_value'],
108
120
  experiment_id: variant_data['experiment_id'],
109
- is_experiment_active: variant_data['is_experiment_active']
121
+ is_experiment_active: variant_data['is_experiment_active'],
122
+ variant_source: VariantSource::REMOTE
110
123
  )
111
124
  end
112
125
 
@@ -1,35 +1,114 @@
1
1
  module Mixpanel
2
2
  module Flags
3
+ # Where a SelectedVariant came from. Set by the providers on every returned
4
+ # variant — coarse-grained (local / remote / fallback). For the specific
5
+ # reason behind a fallback, see {FallbackReason}.
6
+ module VariantSource
7
+ LOCAL = 'local'.freeze
8
+ REMOTE = 'remote'.freeze
9
+ FALLBACK = 'fallback'.freeze
10
+ end
11
+
12
+ # Why the SDK returned the developer fallback. Only meaningful when
13
+ # SelectedVariant#variant_source == VariantSource::FALLBACK.
14
+ #
15
+ # `kind` is the discriminator (matches the PHP constant set). `message`
16
+ # is set on the reasons that carry useful detail (BACKEND_ERROR with the
17
+ # backend's response, MISSING_CONTEXT_KEY with the missing attribute);
18
+ # nil otherwise. The OpenFeature wrapper dispatches on kind and forwards
19
+ # message into ResolutionDetails#error_message.
20
+ class FallbackReason
21
+ KINDS = %i[flag_not_found missing_context_key no_rollout_match backend_error].freeze
22
+
23
+ attr_reader :kind, :message
24
+
25
+ def initialize(kind, message: nil)
26
+ raise ArgumentError, "Unknown FallbackReason kind: #{kind.inspect}" unless KINDS.include?(kind)
27
+
28
+ @kind = kind
29
+ @message = message
30
+ freeze
31
+ end
32
+
33
+ def ==(other)
34
+ other.is_a?(FallbackReason) && other.kind == @kind && other.message == @message
35
+ end
36
+ alias_method :eql?, :==
37
+
38
+ def hash
39
+ [self.class, @kind, @message].hash
40
+ end
41
+
42
+ def to_h
43
+ { kind: @kind, message: @message }.compact
44
+ end
45
+
46
+ # Factory methods. Reasons without meaningful detail return a frozen
47
+ # singleton; reasons with detail allocate per call.
48
+ def self.flag_not_found; FLAG_NOT_FOUND; end
49
+ def self.no_rollout_match; NO_ROLLOUT_MATCH; end
50
+ def self.missing_context_key(key = nil); new(:missing_context_key, message: key); end
51
+ def self.backend_error(message); new(:backend_error, message: message); end
52
+
53
+ FLAG_NOT_FOUND = new(:flag_not_found)
54
+ NO_ROLLOUT_MATCH = new(:no_rollout_match)
55
+ end
56
+
3
57
  # Selected variant returned from flag evaluation
4
58
  class SelectedVariant
5
59
  attr_accessor :variant_key, :variant_value, :experiment_id,
6
- :is_experiment_active, :is_qa_tester
60
+ :is_experiment_active, :is_qa_tester,
61
+ :variant_source, :fallback_reason
7
62
 
8
- # @param variant_key [String, nil] The variant key
9
- # @param variant_value [Object] The variant value (any type)
10
- # @param experiment_id [String, nil] Associated experiment ID
11
- # @param is_experiment_active [Boolean, nil] Whether experiment is active
12
- # @param is_qa_tester [Boolean, nil] Whether user is a QA tester
13
63
  def initialize(variant_key: nil, variant_value: nil, experiment_id: nil,
14
- is_experiment_active: nil, is_qa_tester: nil)
64
+ is_experiment_active: nil, is_qa_tester: nil,
65
+ variant_source: nil, fallback_reason: nil)
15
66
  @variant_key = variant_key
16
67
  @variant_value = variant_value
17
68
  @experiment_id = experiment_id
18
69
  @is_experiment_active = is_experiment_active
19
70
  @is_qa_tester = is_qa_tester
71
+ @variant_source = variant_source
72
+ @fallback_reason = fallback_reason
73
+ end
74
+
75
+ # Return a copy of this variant tagged with the given source. Clears
76
+ # fallback_reason — use {#as_fallback} when returning a fallback.
77
+ def with_source(source)
78
+ copy_with(variant_source: source, fallback_reason: nil)
79
+ end
80
+
81
+ # Return a copy tagged as a fallback with the given reason.
82
+ def as_fallback(reason)
83
+ copy_with(variant_source: VariantSource::FALLBACK, fallback_reason: reason)
20
84
  end
21
85
 
22
86
  # Convert to hash representation
23
- # @return [Hash]
24
87
  def to_h
25
88
  {
26
89
  variant_key: @variant_key,
27
90
  variant_value: @variant_value,
28
91
  experiment_id: @experiment_id,
29
92
  is_experiment_active: @is_experiment_active,
30
- is_qa_tester: @is_qa_tester
93
+ is_qa_tester: @is_qa_tester,
94
+ variant_source: @variant_source,
95
+ fallback_reason: @fallback_reason&.to_h
31
96
  }.compact
32
97
  end
98
+
99
+ private
100
+
101
+ def copy_with(variant_source: @variant_source, fallback_reason: @fallback_reason)
102
+ SelectedVariant.new(
103
+ variant_key: @variant_key,
104
+ variant_value: @variant_value,
105
+ experiment_id: @experiment_id,
106
+ is_experiment_active: @is_experiment_active,
107
+ is_qa_tester: @is_qa_tester,
108
+ variant_source: variant_source,
109
+ fallback_reason: fallback_reason
110
+ )
111
+ end
33
112
  end
34
113
  end
35
114
  end
@@ -1,3 +1,3 @@
1
1
  module Mixpanel
2
- VERSION = '3.2.0'
2
+ VERSION = '3.3.0'
3
3
  end
@@ -201,6 +201,34 @@ When you are done using the provider, shut it down to stop any background pollin
201
201
  provider.shutdown
202
202
  ```
203
203
 
204
+ ## Async Exposure Tracking
205
+
206
+ By default, every flag evaluation tracks an exposure event inline — the `/track` HTTP round trip happens on the calling thread before `fetch_*_value` returns. For latency-sensitive code paths, pass an `:exposure_executor` so exposure tracking runs off-thread. The executor is duck-typed — anything that responds to `#post(&block)` works:
207
+
208
+ ```ruby
209
+ # With concurrent-ruby (recommended)
210
+ require 'concurrent'
211
+ exposure_executor = Concurrent::FixedThreadPool.new(1)
212
+
213
+ provider = Mixpanel::OpenFeature::Provider.from_local(
214
+ 'YOUR_PROJECT_TOKEN',
215
+ { exposure_executor: exposure_executor },
216
+ )
217
+
218
+ # Without concurrent-ruby (minimal wrapper)
219
+ class ThreadPerCall
220
+ def post(&block) = Thread.new(&block)
221
+ end
222
+ provider = Mixpanel::OpenFeature::Provider.from_local(
223
+ 'YOUR_PROJECT_TOKEN',
224
+ { exposure_executor: ThreadPerCall.new },
225
+ )
226
+ ```
227
+
228
+ Available on both local and remote flag configs. Defaults to `nil` (inline behavior); existing setups are unaffected.
229
+
230
+ > **Executor lifecycle:** `provider.shutdown` stops the flags provider's own resources (polling) but does NOT shut down a user-supplied executor. Own the executor's lifecycle explicitly — e.g. `at_exit { exposure_executor.shutdown; exposure_executor.wait_for_termination(5) }` for `Concurrent::FixedThreadPool`. Skipping this can leave background threads alive at process exit and delay shutdown.
231
+
204
232
  ## Error Handling
205
233
 
206
234
  The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation:
@@ -69,16 +69,41 @@ module Mixpanel
69
69
 
70
70
  begin
71
71
  result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true)
72
- rescue StandardError
73
- return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL)
72
+ rescue StandardError => e
73
+ return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message)
74
74
  end
75
75
 
76
- if result.equal?(fallback)
76
+ # variant_source distinguishes local / remote / fallback. When fallback,
77
+ # fallback_reason carries the specific reason (PHP-aligned constants)
78
+ # so we can map each to the spec-correct OpenFeature response instead
79
+ # of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR
80
+ # also carries the backend message, forwarded as error_message.
81
+ case result.fallback_reason&.kind
82
+ when :flag_not_found
77
83
  return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
78
84
  value: default_value,
79
85
  error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
80
86
  reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
81
87
  )
88
+ when :missing_context_key
89
+ return error_result(
90
+ default_value,
91
+ ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING,
92
+ result.fallback_reason.message
93
+ )
94
+ when :no_rollout_match
95
+ # Flag exists, user just didn't match any rollout — per the
96
+ # OpenFeature spec this is `reason: DEFAULT` with no error.
97
+ return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
98
+ value: default_value,
99
+ reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
100
+ )
101
+ when :backend_error
102
+ return error_result(
103
+ default_value,
104
+ ::OpenFeature::SDK::Provider::ErrorCode::GENERAL,
105
+ result.fallback_reason.message
106
+ )
82
107
  end
83
108
 
84
109
  value = result.variant_value
@@ -158,10 +183,11 @@ module Mixpanel
158
183
  end
159
184
  end
160
185
 
161
- def error_result(default_value, error_code)
186
+ def error_result(default_value, error_code, error_message = nil)
162
187
  ::OpenFeature::SDK::Provider::ResolutionDetails.new(
163
188
  value: default_value,
164
189
  error_code: error_code,
190
+ error_message: error_message,
165
191
  reason: ::OpenFeature::SDK::Provider::Reason::ERROR
166
192
  )
167
193
  end
@@ -75,15 +75,25 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
75
75
  def setup_flag(flag_key, value, variant_key: 'variant-key')
76
76
  allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs|
77
77
  if key == flag_key
78
- Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value)
78
+ Mixpanel::Flags::SelectedVariant.new(
79
+ variant_key: variant_key,
80
+ variant_value: value,
81
+ variant_source: Mixpanel::Flags::VariantSource::LOCAL
82
+ )
79
83
  else
80
- fallback
84
+ fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
81
85
  end
82
86
  end
83
87
  end
84
88
 
89
+ def setup_fallback(reason)
90
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs|
91
+ fallback.as_fallback(reason)
92
+ end
93
+ end
94
+
85
95
  def setup_flag_not_found
86
- allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback }
96
+ setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
87
97
  end
88
98
 
89
99
  # --- Metadata ---
@@ -305,6 +315,67 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
305
315
  end
306
316
  end
307
317
 
318
+ # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) ---
319
+
320
+ describe 'no rollout match' do
321
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) }
322
+
323
+ it 'returns DEFAULT reason without an error code' do
324
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true)
325
+ expect(result.value).to be true
326
+ expect(result.reason).to eq('DEFAULT')
327
+ expect(result.error_code).to be_nil
328
+ end
329
+
330
+ it 'works for strings' do
331
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
332
+ expect(result.value).to eq('default')
333
+ expect(result.reason).to eq('DEFAULT')
334
+ expect(result.error_code).to be_nil
335
+ end
336
+ end
337
+
338
+ # --- MISSING_CONTEXT_KEY ---
339
+
340
+ describe 'missing context key' do
341
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) }
342
+
343
+ it 'returns TARGETING_KEY_MISSING for boolean' do
344
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
345
+ expect(result.value).to be false
346
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
347
+ expect(result.reason).to eq('ERROR')
348
+ end
349
+
350
+ it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do
351
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
352
+ expect(result.value).to eq('default')
353
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
354
+ expect(result.error_message).to eq('distinct_id')
355
+ expect(result.reason).to eq('ERROR')
356
+ end
357
+ end
358
+
359
+ # --- BACKEND_ERROR (SDK-83: forwards the backend's response message) ---
360
+
361
+ describe 'backend error' do
362
+ before do
363
+ setup_fallback(
364
+ Mixpanel::Flags::FallbackReason.backend_error(
365
+ 'HTTP 400: distinct_id must be provided in evalContext as a string'
366
+ )
367
+ )
368
+ end
369
+
370
+ it 'maps to GENERAL and forwards the backend message as error_message' do
371
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
372
+ expect(result.value).to eq('default')
373
+ expect(result.error_code).to eq('GENERAL')
374
+ expect(result.reason).to eq('ERROR')
375
+ expect(result.error_message).to include('distinct_id must be provided')
376
+ end
377
+ end
378
+
308
379
  # --- PROVIDER_NOT_READY ---
309
380
 
310
381
  describe 'provider not ready' do
@@ -749,6 +749,179 @@ describe Mixpanel::Flags::LocalFlagsProvider do
749
749
 
750
750
  provider.send(:track_exposure_event, 'test_flag', variant, test_context)
751
751
  end
752
+
753
+ it 'runs the tracker inline by default (no executor configured)' do
754
+ flag = create_test_flag
755
+ stub_flag_definitions([flag])
756
+ provider.start_polling_for_definitions!
757
+
758
+ variant = Mixpanel::Flags::SelectedVariant.new(
759
+ variant_key: 'treatment', variant_value: 'treatment'
760
+ )
761
+
762
+ calling_thread = Thread.current
763
+ tracker_thread = nil
764
+ allow(mock_tracker).to receive(:call) { tracker_thread = Thread.current }
765
+
766
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
767
+ expect(tracker_thread).to be(calling_thread)
768
+ end
769
+
770
+ it 'dispatches the tracker via :exposure_executor when configured' do
771
+ executor = Object.new
772
+ def executor.post(&block)
773
+ Thread.new(&block)
774
+ end
775
+
776
+ tracker_thread = nil
777
+ tracker_ran = Queue.new
778
+ tracker = ->(_distinct_id, _event, _properties) {
779
+ tracker_thread = Thread.current
780
+ tracker_ran << :done
781
+ }
782
+
783
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
784
+ test_token,
785
+ { enable_polling: false, exposure_executor: executor },
786
+ tracker,
787
+ mock_error_handler
788
+ )
789
+
790
+ variant = Mixpanel::Flags::SelectedVariant.new(
791
+ variant_key: 'treatment', variant_value: 'treatment'
792
+ )
793
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
794
+
795
+ # Bounded wait — a bare Queue#pop would hang CI forever if the
796
+ # tracker block raised before pushing :done.
797
+ Timeout.timeout(2) { tracker_ran.pop }
798
+ expect(tracker_thread).not_to be(Thread.current)
799
+ end
800
+
801
+ it 'reports non-MixpanelError exceptions from the async tracker to error_handler' do
802
+ handler_called = Queue.new
803
+ handler = double('error_handler')
804
+ allow(handler).to receive(:handle) { |e| handler_called << e }
805
+
806
+ executor = Object.new
807
+ def executor.post(&block)
808
+ Thread.new(&block)
809
+ end
810
+
811
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
812
+
813
+ provider = Mixpanel::Flags::LocalFlagsProvider.new(
814
+ test_token,
815
+ { enable_polling: false, exposure_executor: executor },
816
+ tracker,
817
+ handler
818
+ )
819
+
820
+ variant = Mixpanel::Flags::SelectedVariant.new(
821
+ variant_key: 'treatment', variant_value: 'treatment'
822
+ )
823
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
824
+
825
+ err = Timeout.timeout(2) { handler_called.pop }
826
+ expect(err).to be_a(Mixpanel::MixpanelError)
827
+ expect(err.message).to include('NoMethodError')
828
+ expect(err.message).to include('boom')
829
+ end
830
+
831
+ # Inline path preserves the pre-executor behavior: non-MixpanelError
832
+ # from the tracker propagates to the flag evaluator's caller instead
833
+ # of being silently reported. Only the async path wraps everything,
834
+ # because on the executor thread propagation would just kill the
835
+ # background thread with no visibility.
836
+ it 'lets non-MixpanelError exceptions propagate on the inline path' do
837
+ tracker = ->(_distinct_id, _event, _properties) { raise NoMethodError, 'boom' }
838
+
839
+ inline_provider = Mixpanel::Flags::LocalFlagsProvider.new(
840
+ test_token,
841
+ { enable_polling: false },
842
+ tracker,
843
+ mock_error_handler
844
+ )
845
+
846
+ variant = Mixpanel::Flags::SelectedVariant.new(
847
+ variant_key: 'treatment', variant_value: 'treatment'
848
+ )
849
+
850
+ expect(mock_error_handler).not_to receive(:handle)
851
+ expect {
852
+ inline_provider.send(:track_exposure_event, 'test_flag', variant, test_context)
853
+ }.to raise_error(NoMethodError, 'boom')
854
+ end
855
+
856
+ # SDK-84: when local eval succeeds via a non-distinct_id Variant Assignment
857
+ # Key but the context lacks distinct_id, the exposure can't fire. Surface
858
+ # via error_handler instead of silently returning.
859
+ it 'reports through error_handler when distinct_id is missing' do
860
+ variant = Mixpanel::Flags::SelectedVariant.new(
861
+ variant_key: 'treatment', variant_value: 'treatment'
862
+ )
863
+
864
+ expect(mock_tracker).not_to receive(:call)
865
+ expect(mock_error_handler).to receive(:handle) do |err|
866
+ expect(err).to be_a(Mixpanel::MixpanelError)
867
+ expect(err.message).to include('test_flag')
868
+ expect(err.message).to include('distinct_id')
869
+ end
870
+
871
+ provider.send(
872
+ :track_exposure_event,
873
+ 'test_flag',
874
+ variant,
875
+ { 'device_id' => 'abc-123' }
876
+ )
877
+ end
878
+ end
879
+
880
+ describe '#get_variant variant_source / fallback_reason tagging' do
881
+ let(:fallback) { Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb') }
882
+
883
+ it 'tags matched variants as LOCAL with no fallback_reason' do
884
+ flag = create_test_flag(rollout_percentage: 100.0)
885
+ stub_flag_definitions([flag])
886
+ provider.start_polling_for_definitions!
887
+
888
+ result = provider.get_variant('test_flag', fallback, test_context)
889
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::LOCAL)
890
+ expect(result.fallback_reason).to be_nil
891
+ expect(result.variant_key).not_to be_nil
892
+ end
893
+
894
+ it 'tags missing flag as FALLBACK / flag_not_found' do
895
+ stub_flag_definitions([])
896
+ provider.start_polling_for_definitions!
897
+
898
+ result = provider.get_variant('missing', fallback, test_context)
899
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
900
+ expect(result.fallback_reason.kind).to eq(:flag_not_found)
901
+ expect(result.fallback_reason.message).to be_nil
902
+ expect(result.variant_value).to eq('fb')
903
+ end
904
+
905
+ it 'tags missing context as FALLBACK / missing_context_key with the missing attribute' do
906
+ flag = create_test_flag(context: 'distinct_id')
907
+ stub_flag_definitions([flag])
908
+ provider.start_polling_for_definitions!
909
+
910
+ result = provider.get_variant('test_flag', fallback, {})
911
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
912
+ expect(result.fallback_reason.kind).to eq(:missing_context_key)
913
+ expect(result.fallback_reason.message).to eq('distinct_id')
914
+ end
915
+
916
+ it 'tags no-rollout-match as FALLBACK / no_rollout_match' do
917
+ flag = create_test_flag(rollout_percentage: 0.0)
918
+ stub_flag_definitions([flag])
919
+ provider.start_polling_for_definitions!
920
+
921
+ result = provider.get_variant('test_flag', fallback, test_context)
922
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
923
+ expect(result.fallback_reason.kind).to eq(:no_rollout_match)
924
+ end
752
925
  end
753
926
 
754
927
  describe 'polling' do
@@ -931,6 +1104,54 @@ describe Mixpanel::Flags::LocalFlagsProvider do
931
1104
  polling_provider.stop_polling_for_definitions!
932
1105
  end
933
1106
  end
1107
+
1108
+ # SDK-78: safe_handle_error previously dispatched only to @error_handler,
1109
+ # whose default Mixpanel::ErrorHandler#handle is a no-op — schema drift
1110
+ # (NoMethodError, JSON::ParserError, etc.) looped forever undetected.
1111
+ # Warn to stderr unconditionally so failures are visible without an
1112
+ # error_handler being configured.
1113
+ it 'warns to stderr when fetch raises and no error_handler is configured' do
1114
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'server down')
1115
+
1116
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1117
+ test_token,
1118
+ { enable_polling: false },
1119
+ mock_tracker,
1120
+ nil
1121
+ )
1122
+
1123
+ expect { polling_provider.start_polling_for_definitions! }
1124
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions: Mixpanel::ServerError/).to_stderr
1125
+ end
1126
+
1127
+ # Regression: previously the outer rescue on start_polling_for_definitions!
1128
+ # caught an initial-fetch failure and returned before the @lifecycle_mutex
1129
+ # block ever spawned the poller thread. A single startup blip (transient
1130
+ # 500 / network timeout) left the SDK permanently without a poller until
1131
+ # the caller retried — the whole point of polling.
1132
+ it 'still spawns the polling thread when the initial fetch fails' do
1133
+ stub_request(:get, endpoint_url_regex).to_return(status: 500, body: 'transient')
1134
+
1135
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
1136
+ test_token,
1137
+ { enable_polling: true, polling_interval_in_seconds: 30 },
1138
+ mock_tracker,
1139
+ nil
1140
+ )
1141
+
1142
+ begin
1143
+ # Warning is expected because the initial fetch fails; capture stderr
1144
+ # so it doesn't pollute test output.
1145
+ expect { polling_provider.start_polling_for_definitions! }
1146
+ .to output(/\[Mixpanel\] Failed to fetch flag definitions/).to_stderr
1147
+
1148
+ polling_thread = polling_provider.instance_variable_get(:@polling_thread)
1149
+ expect(polling_thread).not_to be_nil
1150
+ expect(polling_thread).to be_alive
1151
+ ensure
1152
+ polling_provider.stop_polling_for_definitions!
1153
+ end
1154
+ end
934
1155
  end
935
1156
 
936
1157
  describe 'service account credentials' do
@@ -1,4 +1,5 @@
1
1
  require 'json'
2
+ require 'timeout'
2
3
  require 'mixpanel-ruby/flags/remote_flags_provider'
3
4
  require 'mixpanel-ruby/flags/types'
4
5
  require 'mixpanel-ruby/credentials'
@@ -118,6 +119,61 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
118
119
  provider.get_variant_value('test_flag', 'control', test_context)
119
120
  end
120
121
 
122
+ it 'runs the tracker inline by default (no executor configured)' do
123
+ response = create_success_response({
124
+ 'test_flag' => {
125
+ 'variant_key' => 'treatment',
126
+ 'variant_value' => 'treatment'
127
+ }
128
+ })
129
+ stub_flags_request(response)
130
+
131
+ calling_thread = Thread.current
132
+ tracker_thread = nil
133
+ allow(mock_tracker).to receive(:call) { tracker_thread = Thread.current }
134
+
135
+ provider.get_variant_value('test_flag', 'control', test_context)
136
+ expect(tracker_thread).to be(calling_thread)
137
+ end
138
+
139
+ it 'dispatches the tracker via the configured exposure_executor off the calling thread' do
140
+ response = create_success_response({
141
+ 'test_flag' => {
142
+ 'variant_key' => 'treatment',
143
+ 'variant_value' => 'treatment'
144
+ }
145
+ })
146
+ stub_flags_request(response)
147
+
148
+ calling_thread = Thread.current
149
+ tracker_thread = nil
150
+ tracker_ran = Queue.new
151
+ tracker = ->(_distinct_id, _event, _properties) {
152
+ tracker_thread = Thread.current
153
+ tracker_ran << :done
154
+ }
155
+
156
+ # Minimal duck-typed executor: spawn a thread per call.
157
+ executor = Object.new
158
+ def executor.post(&block)
159
+ Thread.new(&block)
160
+ end
161
+
162
+ provider = Mixpanel::Flags::RemoteFlagsProvider.new(
163
+ test_token,
164
+ { exposure_executor: executor },
165
+ tracker,
166
+ mock_error_handler
167
+ )
168
+
169
+ provider.get_variant_value('test_flag', 'control', test_context)
170
+
171
+ # Bounded wait — a bare Queue#pop would hang CI forever if the
172
+ # tracker block raised before pushing :done.
173
+ Timeout.timeout(2) { tracker_ran.pop }
174
+ expect(tracker_thread).not_to be(calling_thread)
175
+ end
176
+
121
177
  it 'does not track exposure event when report_exposure is false' do
122
178
  response = create_success_response({
123
179
  'test_flag' => {
@@ -259,6 +315,18 @@ describe Mixpanel::Flags::RemoteFlagsProvider do
259
315
 
260
316
  provider.get_variant('any-flag', fallback_variant, test_context)
261
317
  end
318
+
319
+ it 'tags the fallback as BACKEND_ERROR with the response body on HTTP error (SDK-83)' do
320
+ stub_request(:get, %r{https://api\.mixpanel\.com/flags})
321
+ .to_return(status: 400, body: 'distinct_id must be provided in evalContext as a string')
322
+
323
+ fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fb')
324
+ result = provider.get_variant('any-flag', fallback_variant, test_context, report_exposure: false)
325
+
326
+ expect(result.variant_source).to eq(Mixpanel::Flags::VariantSource::FALLBACK)
327
+ expect(result.fallback_reason.kind).to eq(:backend_error)
328
+ expect(result.fallback_reason.message).to include('distinct_id must be provided')
329
+ end
262
330
  end
263
331
 
264
332
  describe '#is_enabled' do
@@ -0,0 +1,55 @@
1
+ require 'json'
2
+ require 'mixpanel-ruby/flags/types'
3
+
4
+ describe Mixpanel::Flags::SelectedVariant do
5
+ describe '#to_h' do
6
+ it 'returns a plain hash for a matched variant' do
7
+ variant = described_class.new(
8
+ variant_key: 'treatment',
9
+ variant_value: 'v1',
10
+ experiment_id: 'exp-1',
11
+ is_experiment_active: true,
12
+ variant_source: Mixpanel::Flags::VariantSource::LOCAL
13
+ )
14
+
15
+ expect(variant.to_h).to eq(
16
+ variant_key: 'treatment',
17
+ variant_value: 'v1',
18
+ experiment_id: 'exp-1',
19
+ is_experiment_active: true,
20
+ variant_source: 'local'
21
+ )
22
+ end
23
+
24
+ # SDK-125: previously to_h returned the raw FallbackReason object under
25
+ # :fallback_reason, so downstream serializers (e.g. .to_json,
26
+ # structured logging) got an object representation instead of the
27
+ # {kind:, message:} hash FallbackReason itself defines.
28
+ it 'recurses into FallbackReason so the result is fully hash-y' do
29
+ variant = described_class.new(
30
+ variant_value: 'fallback-value',
31
+ variant_source: Mixpanel::Flags::VariantSource::FALLBACK,
32
+ fallback_reason: Mixpanel::Flags::FallbackReason.backend_error('boom')
33
+ )
34
+
35
+ expect(variant.to_h[:fallback_reason]).to eq(kind: :backend_error, message: 'boom')
36
+ end
37
+
38
+ it 'round-trips through JSON without leaking a FallbackReason object' do
39
+ variant = described_class.new(
40
+ variant_value: 'fallback-value',
41
+ variant_source: Mixpanel::Flags::VariantSource::FALLBACK,
42
+ fallback_reason: Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')
43
+ )
44
+
45
+ parsed = JSON.parse(variant.to_h.to_json, symbolize_names: true)
46
+
47
+ expect(parsed[:fallback_reason]).to eq(kind: 'missing_context_key', message: 'distinct_id')
48
+ end
49
+
50
+ it 'compacts fallback_reason when absent' do
51
+ variant = described_class.new(variant_value: 'x', variant_source: 'local')
52
+ expect(variant.to_h).not_to have_key(:fallback_reason)
53
+ end
54
+ end
55
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixpanel-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.0
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mixpanel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-10 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mutex_m
@@ -221,6 +221,7 @@ files:
221
221
  - spec/mixpanel-ruby/events_spec.rb
222
222
  - spec/mixpanel-ruby/flags/local_flags_spec.rb
223
223
  - spec/mixpanel-ruby/flags/remote_flags_spec.rb
224
+ - spec/mixpanel-ruby/flags/types_spec.rb
224
225
  - spec/mixpanel-ruby/flags/utils_spec.rb
225
226
  - spec/mixpanel-ruby/groups_spec.rb
226
227
  - spec/mixpanel-ruby/people_spec.rb