mixpanel-ruby 3.2.0 → 3.4.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: c087578749eaaa76591b16c382aef85e38e276501c39c8a82e5ff155e2572747
4
+ data.tar.gz: edc867253e2a32dafa92dc32cc318d6aab740de9a776955715eef42972810b90
5
5
  SHA512:
6
- metadata.gz: 0f8236551ad44db0c9b3afdcefa17083bbca02ff00b6b9a236123857eaf38595070b258c923671b0f0672c76aa6baadefc7d169e02f27ec33cb475b4b9eabb70
7
- data.tar.gz: 469d8373977e082acc887a8e11d8eeb973190a3488f40d5bb6fa66f4be7cd5c6e83772923e3ee291641f7aa25d8b0dd6d6123541f2e2ed90677c3b34f0e48bba
6
+ metadata.gz: a635485dfad21d6c20c7beafb831bd27af2333ca59b2cf3e04e3be6fd09dbc506c4ac67b160415a6d70646e9068290ffdcd6160453dee0b88ff4a1ec799e7253
7
+ data.tar.gz: 173b6b1e59c299c5ddbf3c36f02ef64893388d55ea94671fb4da15a8e636e7bc2c943cf4b5f9c81a941f20a73e3c97cd508fd681d21dca85b25b94b3f612514f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [v3.4.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.4.0) (2026-09-01)
4
+
5
+ ### Features
6
+ - Implement semver and date custom ops for flags runtime props ([#171](https://github.com/mixpanel/mixpanel-ruby/pull/171))
7
+
8
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/v3.3.0...v3.4.0)
9
+
10
+ ## [v3.3.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.3.0) (2026-07-24)
11
+
12
+ ### Fixes
13
+ - allow capability to offload reportExposure to async thread (SDK-80) ([#157](https://github.com/mixpanel/mixpanel-ruby/pull/157))
14
+ - surface dropped exposure when distinct_id missing from context ([#154](https://github.com/mixpanel/mixpanel-ruby/pull/154))
15
+ - distinguish fallback reasons + forward backend error message (SDK-79, SDK-83) ([#153](https://github.com/mixpanel/mixpanel-ruby/pull/153))
16
+
17
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/v3.2.0...v3.3.0)
18
+
3
19
  ## [v3.2.0](https://github.com/mixpanel/mixpanel-ruby/tree/v3.2.0) (2026-07-10)
4
20
 
5
21
  ### 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
+ ##### _September 01, 2026_ - [v3.4.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/v3.4.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.
@@ -0,0 +1,207 @@
1
+ require 'date'
2
+ require 'time'
3
+ require 'json_logic'
4
+
5
+ module Mixpanel
6
+ module Flags
7
+ module CustomOperators
8
+ # Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation
9
+ # differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid
10
+ # as part of the Semver 2.0.0 spec. See https://semver.org/
11
+ SEMVER_STRICT = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/
12
+
13
+ # Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the
14
+ # calendar can be validated separately; the pattern only constrains their shape.
15
+ RFC3339_STRICT = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/
16
+
17
+ # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this.
18
+ SEMVER_PARTS = 3
19
+
20
+ # Longest operand the semver regex is allowed to see. A real version never approaches this; the
21
+ # bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the
22
+ # regex regardless of how the engine schedules backtracking.
23
+ MAX_SEMVER_LENGTH = 256
24
+
25
+ # Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range.
26
+ MAX_EPOCH_MS = 2**63
27
+
28
+ module_function
29
+
30
+ # Implements a custom operation for semantic versioning comparison that conforms to the
31
+ # semver 2.0.0 standard. Prior to comparison, any leading version prefix is stripped.
32
+ def semver_compare(values)
33
+ unpacked = operands(values)
34
+ return false unless unpacked
35
+
36
+ actual, symbol, target = unpacked
37
+ return false unless actual.is_a?(String) && target.is_a?(String)
38
+ return false if actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH
39
+
40
+ actual_version = normalize_semver(actual)
41
+ target_version = normalize_semver(target)
42
+ return false unless actual_version.match?(SEMVER_STRICT) && target_version.match?(SEMVER_STRICT)
43
+
44
+ cmp = compare_semver(actual_version, target_version)
45
+ comparator_matches?(cmp, symbol)
46
+ end
47
+
48
+ # Strip optional build metadata and separate the core version from pre-release identifiers
49
+ def split_semver(version)
50
+ plus = version.index('+')
51
+ version = version[0, plus] if plus
52
+ dash = version.index('-')
53
+ return [version.split('.'), []] unless dash
54
+
55
+ [version[0, dash].split('.'), version[(dash + 1)..-1].split('.')]
56
+ end
57
+
58
+ def numeric_identifier?(identifier)
59
+ identifier.match?(/\A[0-9]+\z/)
60
+ end
61
+
62
+ # Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number.
63
+ # Comparing them as digits rather than parsing to a fixed-width integer keeps versions that
64
+ # overflow a 64-bit integer ordered correctly.
65
+ def compare_numeric(a, b)
66
+ return a.length <=> b.length unless a.length == b.length
67
+
68
+ a <=> b
69
+ end
70
+
71
+ # SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an
72
+ # alphanumeric one, and anything else compares by ASCII order.
73
+ def compare_prerelease_identifier(a, b)
74
+ a_numeric = numeric_identifier?(a)
75
+ b_numeric = numeric_identifier?(b)
76
+ return compare_numeric(a, b) if a_numeric && b_numeric
77
+ return -1 if a_numeric
78
+ return 1 if b_numeric
79
+
80
+ a <=> b
81
+ end
82
+
83
+ # Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched
84
+ # against the official regex, so the core holds exactly three numeric identifiers and every
85
+ # prerelease field is well-formed; the split needs no error path.
86
+ def compare_semver(actual, target)
87
+ actual_core, actual_prerelease = split_semver(actual)
88
+ target_core, target_prerelease = split_semver(target)
89
+
90
+ actual_core.each_with_index do |part, index|
91
+ result = compare_numeric(part, target_core[index])
92
+ return result unless result.zero?
93
+ end
94
+
95
+ # A prerelease ranks below the release it belongs to (section 11.3).
96
+ return 0 if actual_prerelease.empty? && target_prerelease.empty?
97
+ return 1 if actual_prerelease.empty?
98
+ return -1 if target_prerelease.empty?
99
+
100
+ [actual_prerelease.length, target_prerelease.length].min.times do |index|
101
+ result = compare_prerelease_identifier(actual_prerelease[index], target_prerelease[index])
102
+ return result unless result.zero?
103
+ end
104
+ # Every field so far is equal, so the longer list wins (section 11.4.4).
105
+ actual_prerelease.length <=> target_prerelease.length
106
+ end
107
+
108
+ # Implements a custom operation for datetime comparison. The target value stored on the
109
+ # feature flag is the millisecond epoch, whereas the actual value provided at evaluation
110
+ # time must be RFC-3339 formatted.
111
+ def datetime_compare(values)
112
+ unpacked = operands(values)
113
+ return false unless unpacked
114
+
115
+ actual, symbol, target = unpacked
116
+ actual_sec = convert_rfc3339_to_unix_seconds(actual)
117
+ target_sec = convert_unix_milliseconds_to_seconds(target)
118
+ return false unless actual_sec && target_sec
119
+
120
+ cmp = actual_sec - target_sec
121
+ comparator_matches?(cmp, symbol)
122
+ end
123
+
124
+ def operands(values)
125
+ return nil unless values.length == 3
126
+
127
+ actual, symbol, target = values
128
+ return nil unless symbol.is_a?(String)
129
+
130
+ [actual, symbol, target]
131
+ end
132
+
133
+ def comparator_matches?(cmp, symbol)
134
+ case symbol
135
+ when '===' then cmp.zero?
136
+ when '!==' then !cmp.zero?
137
+ when '<' then cmp < 0
138
+ when '<=' then cmp <= 0
139
+ when '>' then cmp > 0
140
+ when '>=' then cmp >= 0
141
+ else false
142
+ end
143
+ end
144
+
145
+ def normalize_semver(str)
146
+ stripped = str.strip
147
+ stripped = stripped[1..] if stripped =~ /\Av/i
148
+
149
+ suffix_start = stripped.length
150
+ ['-', '+'].each do |separator|
151
+ index = stripped.index(separator)
152
+ suffix_start = index if index && index < suffix_start
153
+ end
154
+
155
+ core = stripped[0, suffix_start]
156
+ suffix = stripped[suffix_start..] || ''
157
+
158
+ # split(-1) keeps trailing empty fields, so "1." and "1.2.3." stay malformed instead of
159
+ # silently padding to a valid version. Returning the input unchanged lets the validator reject it.
160
+ segments = core.split('.', -1)
161
+ return stripped unless segments.length.between?(1, SEMVER_PARTS) && segments.all? { |seg| seg.match?(/\A\d+\z/) }
162
+
163
+ segments += ['0'] * (SEMVER_PARTS - segments.length)
164
+ segments.join('.') + suffix
165
+ end
166
+
167
+ # The pattern constrains each field to two digits, which still admits a date that cannot exist,
168
+ # such as 2026-02-30 or 29 February in a common year. Time.iso8601 rolls those forward into a
169
+ # real instant instead of raising, and hour 24 likewise becomes the following midnight, so the
170
+ # calendar is checked here. RFC 3339 section 5.6 allows hours 00 through 23.
171
+ def real_calendar_date?(year, month, day, hour)
172
+ hour <= 23 && Date.valid_date?(year, month, day)
173
+ end
174
+
175
+ def convert_rfc3339_to_unix_seconds(value)
176
+ return nil unless value.is_a?(String)
177
+
178
+ normalized = value.strip.upcase
179
+ fields = RFC3339_STRICT.match(normalized)
180
+ return nil unless fields
181
+ return nil unless real_calendar_date?(fields[1].to_i, fields[2].to_i, fields[3].to_i, fields[4].to_i)
182
+
183
+ parsed = Time.iso8601(normalized)
184
+ parsed.to_i
185
+ rescue ArgumentError
186
+ nil
187
+ end
188
+
189
+ def convert_unix_milliseconds_to_seconds(value)
190
+ return nil unless value.is_a?(Numeric)
191
+ # A value int64 cannot represent is not a real timestamp; treating one as a bound would let a
192
+ # nonsense target define a rollout window. NaN fails this comparison too.
193
+ return nil unless value.abs < MAX_EPOCH_MS
194
+
195
+ value.to_i.fdiv(1000).truncate
196
+ end
197
+ end
198
+ end
199
+ end
200
+
201
+ JsonLogic.add_operation('semver_compare') do |values, _data|
202
+ Mixpanel::Flags::CustomOperators.semver_compare(values)
203
+ end
204
+
205
+ JsonLogic.add_operation('datetime_compare') do |values, _data|
206
+ Mixpanel::Flags::CustomOperators.datetime_compare(values)
207
+ end
@@ -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
@@ -1,6 +1,7 @@
1
1
  require 'thread'
2
2
  require 'json_logic'
3
3
  require 'mixpanel-ruby/flags/flags_provider'
4
+ require 'mixpanel-ruby/flags/custom_operators'
4
5
 
5
6
  module Mixpanel
6
7
  module Flags
@@ -11,7 +12,8 @@ module Mixpanel
11
12
  api_host: 'api.mixpanel.com',
12
13
  request_timeout_in_seconds: 10,
13
14
  enable_polling: true,
14
- polling_interval_in_seconds: 60
15
+ polling_interval_in_seconds: 60,
16
+ exposure_executor: nil
15
17
  }.freeze
16
18
 
17
19
  # @param token [String] Mixpanel project token
@@ -37,6 +39,7 @@ module Mixpanel
37
39
  token: token,
38
40
  api_host: @config[:api_host],
39
41
  request_timeout_in_seconds: @config[:request_timeout_in_seconds],
42
+ exposure_executor: @config[:exposure_executor],
40
43
  credentials: credentials
41
44
  }
42
45
 
@@ -71,7 +74,17 @@ module Mixpanel
71
74
  # blocking HTTP call, and holding the lifecycle lock across it would
72
75
  # block a concurrent stop_polling_for_definitions! for the full request
73
76
  # timeout.
74
- fetch_flag_definitions
77
+ #
78
+ # A transient failure here (network blip, HTTP 500) should NOT prevent
79
+ # the polling thread from spawning — the loop retries on the configured
80
+ # interval. Without this inner rescue, the outer rescue below would
81
+ # catch and return, leaving the SDK permanently without polling until
82
+ # the caller manually retried.
83
+ begin
84
+ fetch_flag_definitions
85
+ rescue StandardError => e
86
+ safe_handle_error(e)
87
+ end
75
88
 
76
89
  @lifecycle_mutex.synchronize do
77
90
  # If a stop arrived during/after our @stop_polling clear above, abort
@@ -159,11 +172,11 @@ module Mixpanel
159
172
  def get_variant(flag_key, fallback_variant, context, report_exposure: true)
160
173
  flag = @flag_definitions[flag_key]
161
174
 
162
- return fallback_variant unless flag
175
+ return fallback_variant.as_fallback(FallbackReason.flag_not_found) unless flag
163
176
 
164
177
  context_key = flag['context']
165
178
  unless context.key?(context_key) || context.key?(context_key.to_sym)
166
- return fallback_variant
179
+ return fallback_variant.as_fallback(FallbackReason.missing_context_key(context_key))
167
180
  end
168
181
 
169
182
  context_value = context[context_key] || context[context_key.to_sym]
@@ -175,10 +188,10 @@ module Mixpanel
175
188
  selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
176
189
  end
177
190
 
178
- return fallback_variant unless selected_variant
191
+ return fallback_variant.as_fallback(FallbackReason.no_rollout_match) unless selected_variant
179
192
 
180
193
  track_exposure_event(flag_key, selected_variant, context) if report_exposure
181
- selected_variant
194
+ selected_variant.with_source(VariantSource::LOCAL)
182
195
  end
183
196
 
184
197
  # Get all variants for user context
@@ -187,10 +200,11 @@ module Mixpanel
187
200
  # @return [Hash] Map of flag_key => SelectedVariant
188
201
  def get_all_variants(context)
189
202
  variants = {}
203
+ fallback = SelectedVariant.new(variant_value: nil)
190
204
 
191
205
  @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
206
+ variant = get_variant(flag_key, fallback, context, report_exposure: false)
207
+ variants[flag_key] = variant if variant.variant_source == VariantSource::LOCAL
194
208
  end
195
209
 
196
210
  variants
@@ -201,11 +215,19 @@ module Mixpanel
201
215
  # Wrap @error_handler.handle so a misbehaving handler can't kill the
202
216
  # polling thread mid-loop — that would leave @polling_thread non-nil but
203
217
  # dead, and (without the .alive? check in start) silently prevent restart.
218
+ #
219
+ # Always warn to stderr as well. The default Mixpanel::ErrorHandler#handle
220
+ # is a no-op, so dispatching only via @error_handler swallows schema drift
221
+ # (NoMethodError, JSON::ParserError, etc.) — the loop runs forever
222
+ # undetected. Matches the convention in mixpanel-python / mixpanel-java /
223
+ # mixpanel-go / mixpanel-node, all of which log unconditionally and keep
224
+ # polling.
204
225
  def safe_handle_error(error)
226
+ warn "[Mixpanel] Failed to fetch flag definitions: #{error.class}: #{error.message}"
205
227
  @error_handler.handle(error) if @error_handler
206
228
  rescue StandardError
207
- # Swallow: keeping the polling loop alive is more important than
208
- # propagating a broken handler's failure.
229
+ # Swallow handler failures: keeping the polling loop alive is more
230
+ # important than propagating a broken handler's failure.
209
231
  end
210
232
 
211
233
  def fetch_flag_definitions
@@ -356,7 +378,10 @@ module Mixpanel
356
378
  begin
357
379
  rule = lowercase_only_leaf_nodes(runtime_rule)
358
380
  result = JsonLogic.apply(rule, parameters)
359
- !!result
381
+ # A well-formed runtime rule evaluates to a boolean. Anything else —
382
+ # notably an unrecognized operator, which the engine echoes back as a
383
+ # (truthy) hash rather than raising — fails closed.
384
+ result == true
360
385
  rescue StandardError => e
361
386
  @error_handler.handle(e) if @error_handler
362
387
  false
@@ -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.4.0'
3
3
  end
@@ -15,7 +15,7 @@ spec = Gem::Specification.new do |spec|
15
15
  spec.required_ruby_version = '>= 3.0.0'
16
16
  spec.add_runtime_dependency 'mutex_m'
17
17
  spec.add_runtime_dependency "base64"
18
- spec.add_runtime_dependency 'json-logic-rb', '~> 0.1.5'
18
+ spec.add_runtime_dependency 'json-logic-rb', '~> 0.2'
19
19
 
20
20
  spec.add_development_dependency 'activesupport', '~> 4.0'
21
21
  spec.add_development_dependency 'rake', '~> 13'
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [openfeature/v0.2.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.2.0) (2026-07-28)
4
+
5
+ ### Features
6
+ - add service account support ([#152](https://github.com/mixpanel/mixpanel-ruby/pull/152))
7
+
8
+ ### Chores
9
+ - pin mixpanel-ruby ~> 3.3 for fallback_reason (SDK-126) ([#166](https://github.com/mixpanel/mixpanel-ruby/pull/166))
10
+
11
+ [Full Changelog](https://github.com/mixpanel/mixpanel-ruby/compare/openfeature/v0.1.0...openfeature/v0.2.0)
12
+
3
13
  ## [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/tree/openfeature/v0.1.0) (2026-05-13)
4
14
 
5
15
  Initial release of the Mixpanel OpenFeature provider for Ruby.