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 +4 -4
- data/CHANGELOG.md +16 -0
- data/Readme.rdoc +1 -1
- data/lib/mixpanel-ruby/flags/custom_operators.rb +207 -0
- data/lib/mixpanel-ruby/flags/flags_provider.rb +42 -5
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +36 -11
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +18 -5
- data/lib/mixpanel-ruby/flags/types.rb +88 -9
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/mixpanel-ruby.gemspec +1 -1
- data/openfeature-provider/CHANGELOG.md +10 -0
- data/openfeature-provider/README.md +29 -1
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +30 -4
- data/openfeature-provider/lib/mixpanel/openfeature/version.rb +1 -1
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +1 -1
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +74 -3
- data/spec/fixtures/datetime_compare_tests.json +100 -0
- data/spec/fixtures/semver_compare_tests.json +161 -0
- data/spec/mixpanel-ruby/flags/custom_operators_spec.rb +74 -0
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +283 -0
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +68 -0
- data/spec/mixpanel-ruby/flags/types_spec.rb +55 -0
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c087578749eaaa76591b16c382aef85e38e276501c39c8a82e5ff155e2572747
|
|
4
|
+
data.tar.gz: edc867253e2a32dafa92dc32cc318d6aab740de9a776955715eef42972810b90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#####
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/mixpanel-ruby.gemspec
CHANGED
|
@@ -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.
|
|
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.
|