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 +4 -4
- data/CHANGELOG.md +9 -0
- data/Readme.rdoc +1 -1
- data/lib/mixpanel-ruby/flags/flags_provider.rb +42 -5
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +31 -10
- 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/openfeature-provider/README.md +28 -0
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +30 -4
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +74 -3
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +221 -0
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +68 -0
- data/spec/mixpanel-ruby/flags/types_spec.rb +55 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1be60e96a1c17bd58aa1e6a1b9c17228a2b4934966c95bd893efa66d6a4a4f54
|
|
4
|
+
data.tar.gz: 8081213f4003791117e588c7afa479d249dd79ec219e16f109dda5ef26a0224c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|