mixpanel-ruby 2.3.0 → 3.0.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: 0a4bc0206903c9c7d7e61253d7cfe248e17f698a754dac067e3d6997ee44688a
4
- data.tar.gz: eda4910784b73583c7ff29fcbeaae86b1259e407d8cddf5c5c9ccb7ad9004661
3
+ metadata.gz: 99f6ea359c1b7a0f4248035f2ebe9a13380ee647b20722f73f49f45b3bb62ea3
4
+ data.tar.gz: c1dc8bf0e37d90a397654b263059458291eff144987b87bc3a9dcc153649c42e
5
5
  SHA512:
6
- metadata.gz: ba267b81907f663ef400b694973569c2e2e00c323e8e199bea82516d3552c73f2ffcc6c864c0fca2123f53a3e246218e60c64e58b51aab5ac96792dae8f76a13
7
- data.tar.gz: 541c0b8b4da498f37a749d76542fad417c776c83f4d583c8ae5bbbfbba9979103cfb634bb4a3d8ae6748b6b302ca546524edc80258423eb2e862d279328d07f1
6
+ metadata.gz: cee44f99c3d4000ee9eb64a0353abcee02bec2a422d0a27c9cda160c8bb50db3f6abe72c550730633c719a51eb06f92dbba5e38cc17f7266a82bf8561488005e
7
+ data.tar.gz: db60ba774c9e32e2bb4199d46a3c192a40d35d3aaef84fdb87c0e29a7a66f76b4f4dbd0c6e2f28d5d7145844fadb91b9ccefb4bd82d2d9e2f287153182fcf176
@@ -0,0 +1,37 @@
1
+ name: Ruby
2
+
3
+ on:
4
+ push:
5
+ branches: [ "master" ]
6
+ pull_request: {}
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+
14
+ runs-on: ubuntu-latest
15
+ strategy:
16
+ matrix:
17
+ ruby-version:
18
+ - '3.0'
19
+ - '3.1'
20
+ - '3.2'
21
+ - '3.3'
22
+ - '3.4'
23
+
24
+ steps:
25
+ - uses: actions/checkout@v6
26
+ - name: Set up Ruby
27
+ uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0
28
+ with:
29
+ ruby-version: ${{ matrix.ruby-version }}
30
+ bundler-cache: true # runs 'bundle install' and caches installed gems automatically
31
+ - name: Run tests
32
+ run: bundle exec rake
33
+ - name: Upload coverage reports to Codecov
34
+ uses: codecov/codecov-action@v5
35
+ with:
36
+ token: ${{ secrets.CODECOV_TOKEN }}
37
+ slug: mixpanel/mixpanel-ruby
data/Readme.rdoc CHANGED
@@ -1,5 +1,3 @@
1
- {<img src="https://travis-ci.org/mixpanel/mixpanel-ruby.svg?branch=master" alt="Build Status" />}[https://travis-ci.org/mixpanel/mixpanel-ruby]
2
-
3
1
  = mixpanel-ruby: The official Mixpanel Ruby library
4
2
 
5
3
  mixpanel-ruby is a library for tracking events and sending \Mixpanel profile
@@ -49,6 +47,14 @@ In particular, for Rails apps, the following projects are currently actively mai
49
47
 
50
48
  == Changes
51
49
 
50
+ == 2.3.1
51
+ * Convert timestamps to milliseconds and update Ruby compatibility
52
+
53
+ == 2.3.0
54
+ * Clear submitted slices during BufferedConsumer#flush
55
+ * Groups analytics support
56
+ * use millisecond precision for time properties
57
+
52
58
  == 2.2.2
53
59
  * Add Group Analytics support with Mixpanel::Groups
54
60
 
@@ -0,0 +1,25 @@
1
+ require 'mixpanel-ruby'
2
+
3
+ # Configuration
4
+ PROJECT_TOKEN = ""
5
+ FLAG_KEY = "sample-flag"
6
+ FLAG_FALLBACK_VARIANT = "control"
7
+ USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
8
+ API_HOST = "api.mixpanel.com"
9
+ SHOULD_POLL_CONTINUOUSLY = true
10
+ POLLING_INTERVAL_SECONDS = 15
11
+
12
+ local_config = {
13
+ api_host: API_HOST,
14
+ enable_polling: SHOULD_POLL_CONTINUOUSLY,
15
+ polling_interval_in_seconds: POLLING_INTERVAL_SECONDS
16
+ }
17
+
18
+ tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, local_flags_config: local_config)
19
+
20
+ tracker.local_flags.start_polling_for_definitions!
21
+
22
+ variant_value = tracker.local_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
23
+ puts "Variant value: #{variant_value}"
24
+
25
+ tracker.local_flags.stop_polling_for_definitions!
@@ -0,0 +1,18 @@
1
+ require 'mixpanel-ruby'
2
+
3
+ # Configuration
4
+ PROJECT_TOKEN = ""
5
+ FLAG_KEY = "sample-flag"
6
+ FLAG_FALLBACK_VARIANT = "control"
7
+ USER_CONTEXT = { "distinct_id" => "ruby-demo-user" }
8
+ API_HOST = "api.mixpanel.com"
9
+
10
+ remote_config = {
11
+ api_host: API_HOST
12
+ }
13
+
14
+ tracker = Mixpanel::Tracker.new(PROJECT_TOKEN, remote_flags_config: remote_config)
15
+
16
+ variant_value = tracker.remote_flags.get_variant_value(FLAG_KEY, FLAG_FALLBACK_VARIANT, USER_CONTEXT)
17
+ puts "Variant value: #{variant_value}"
18
+
@@ -55,7 +55,7 @@ module Mixpanel
55
55
  properties = {
56
56
  'distinct_id' => distinct_id,
57
57
  'token' => @token,
58
- 'time' => Time.now.to_f,
58
+ 'time' => (Time.now.to_f * 1000).to_i,
59
59
  'mp_lib' => 'ruby',
60
60
  '$lib_version' => Mixpanel::VERSION,
61
61
  }.merge(properties)
@@ -103,7 +103,7 @@ module Mixpanel
103
103
  properties = {
104
104
  'distinct_id' => distinct_id,
105
105
  'token' => @token,
106
- 'time' => Time.now.to_f,
106
+ 'time' => (Time.now.to_f * 1000).to_i,
107
107
  'mp_lib' => 'ruby',
108
108
  '$lib_version' => Mixpanel::VERSION,
109
109
  }.merge(properties)
@@ -0,0 +1,111 @@
1
+ require 'net/https'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'mixpanel-ruby/version'
5
+ require 'mixpanel-ruby/error'
6
+ require 'mixpanel-ruby/flags/utils'
7
+ require 'mixpanel-ruby/flags/types'
8
+
9
+ module Mixpanel
10
+ module Flags
11
+
12
+ # Base class for feature flags providers
13
+ # Provides common HTTP handling and exposure event tracking
14
+ class FlagsProvider
15
+ # @param provider_config [Hash] Configuration with :token, :api_host, :request_timeout_in_seconds
16
+ # @param endpoint [String] API endpoint path (e.g., '/flags' or '/flags/definitions')
17
+ # @param tracker_callback [Proc] Function used to track events (bound tracker.track method)
18
+ # @param evaluation_mode [String] The feature flag evaluation mode. This is either 'local' or 'remote'
19
+ # @param error_handler [Mixpanel::ErrorHandler] Error handler instance
20
+ def initialize(provider_config, endpoint, tracker_callback, evaluation_mode, error_handler)
21
+ @provider_config = provider_config
22
+ @endpoint = endpoint
23
+ @tracker_callback = tracker_callback
24
+ @evaluation_mode = evaluation_mode
25
+ @error_handler = error_handler
26
+ end
27
+
28
+ # Make HTTP request to flags API endpoint
29
+ # @param additional_params [Hash, nil] Additional query parameters
30
+ # @return [Hash] Parsed JSON response
31
+ # @raise [Mixpanel::ConnectionError] on network errors
32
+ # @raise [Mixpanel::ServerError] on HTTP errors
33
+ def call_flags_endpoint(additional_params = nil)
34
+ common_params = Utils.prepare_common_query_params(
35
+ @provider_config[:token],
36
+ Mixpanel::VERSION
37
+ )
38
+
39
+ params = common_params.merge(additional_params || {})
40
+ query_string = URI.encode_www_form(params)
41
+
42
+ uri = URI::HTTPS.build(
43
+ host: @provider_config[:api_host],
44
+ path: @endpoint,
45
+ query: query_string
46
+ )
47
+
48
+ http = Net::HTTP.new(uri.host, uri.port)
49
+
50
+ http.use_ssl = true
51
+ http.open_timeout = @provider_config[:request_timeout_in_seconds]
52
+ http.read_timeout = @provider_config[:request_timeout_in_seconds]
53
+
54
+ request = Net::HTTP::Get.new(uri.request_uri)
55
+
56
+ request.basic_auth(@provider_config[:token], '')
57
+
58
+ request['Content-Type'] = 'application/json'
59
+ request['traceparent'] = Utils.generate_traceparent()
60
+
61
+ begin
62
+ response = http.request(request)
63
+
64
+ unless response.code.to_i == 200
65
+ raise ServerError.new("HTTP #{response.code}: #{response.body}")
66
+ end
67
+
68
+ JSON.parse(response.body)
69
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
70
+ raise ConnectionError.new("Request timeout: #{e.message}")
71
+ rescue JSON::ParserError => e
72
+ raise ServerError.new("Invalid JSON response: #{e.message}")
73
+ rescue StandardError => e
74
+ raise ConnectionError.new("Network error: #{e.message}")
75
+ end
76
+ end
77
+
78
+ # Track exposure event to Mixpanel
79
+ # @param flag_key [String] Feature flag key
80
+ # @param selected_variant [SelectedVariant] The selected variant
81
+ # @param context [Hash] User context (must include 'distinct_id')
82
+ # @param latency_ms [Integer, nil] Optional latency in milliseconds
83
+ def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil)
84
+ distinct_id = context['distinct_id'] || context[:distinct_id]
85
+
86
+ unless distinct_id
87
+ return
88
+ end
89
+
90
+ properties = {
91
+ 'distinct_id' => distinct_id,
92
+ 'Experiment name' => flag_key,
93
+ 'Variant name' => selected_variant.variant_key,
94
+ '$experiment_type' => 'feature_flag',
95
+ 'Flag evaluation mode' => @evaluation_mode
96
+ }
97
+
98
+ properties['Variant fetch latency (ms)'] = latency_ms if latency_ms
99
+ properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id
100
+ properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
101
+ properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
102
+
103
+ begin
104
+ @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
105
+ rescue MixpanelError => e
106
+ @error_handler.handle(e)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,303 @@
1
+ require 'thread'
2
+ require 'json_logic'
3
+ require 'mixpanel-ruby/flags/flags_provider'
4
+
5
+ module Mixpanel
6
+ module Flags
7
+ # Local feature flags provider
8
+ # Evaluates flags client-side with cached flag definitions
9
+ class LocalFlagsProvider < FlagsProvider
10
+ DEFAULT_CONFIG = {
11
+ api_host: 'api.mixpanel.com',
12
+ request_timeout_in_seconds: 10,
13
+ enable_polling: true,
14
+ polling_interval_in_seconds: 60
15
+ }.freeze
16
+
17
+ # @param token [String] Mixpanel project token
18
+ # @param config [Hash] Local flags configuration
19
+ # @param tracker_callback [Proc] Callback to track events
20
+ # @param error_handler [Mixpanel::ErrorHandler] Error handler
21
+ def initialize(token, config, tracker_callback, error_handler)
22
+ @config = DEFAULT_CONFIG.merge(config || {})
23
+
24
+ provider_config = {
25
+ token: token,
26
+ api_host: @config[:api_host],
27
+ request_timeout_in_seconds: @config[:request_timeout_in_seconds]
28
+ }
29
+
30
+ super(provider_config, '/flags/definitions', tracker_callback, 'local', error_handler)
31
+
32
+ @flag_definitions = {}
33
+ @polling_thread = nil
34
+ @stop_polling = false
35
+ end
36
+
37
+ # Start polling for flag definitions
38
+ # Fetches immediately, then at regular intervals if polling enabled
39
+ def start_polling_for_definitions!
40
+ fetch_flag_definitions
41
+
42
+ if @config[:enable_polling] && !@polling_thread
43
+ @stop_polling = false
44
+ @polling_thread = Thread.new do
45
+ loop do
46
+ sleep @config[:polling_interval_in_seconds]
47
+ break if @stop_polling
48
+
49
+ begin
50
+ fetch_flag_definitions
51
+ rescue StandardError => e
52
+ @error_handler.handle(e) if @error_handler
53
+ end
54
+ end
55
+ end
56
+ end
57
+ rescue StandardError => e
58
+ @error_handler.handle(e) if @error_handler
59
+ end
60
+
61
+ def stop_polling_for_definitions!
62
+ @stop_polling = true
63
+ @polling_thread&.join
64
+ @polling_thread = nil
65
+ end
66
+
67
+ # Check if flag is enabled (for boolean flags)
68
+ # @param flag_key [String] Feature flag key
69
+ # @param context [Hash] Evaluation context (must include 'distinct_id')
70
+ # @return [Boolean]
71
+ def is_enabled?(flag_key, context)
72
+ value = get_variant_value(flag_key, false, context)
73
+ value == true
74
+ end
75
+
76
+ # Get variant value for a flag
77
+ # @param flag_key [String] Feature flag key
78
+ # @param fallback_value [Object] Fallback value if not in rollout
79
+ # @param context [Hash] Evaluation context
80
+ # @param report_exposure [Boolean] Whether to track exposure event
81
+ # @return [Object] The variant value
82
+ def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
83
+ result = get_variant(
84
+ flag_key,
85
+ SelectedVariant.new(variant_value: fallback_value),
86
+ context,
87
+ report_exposure: report_exposure
88
+ )
89
+ result.variant_value
90
+ end
91
+
92
+ # Get complete variant information
93
+ # @param flag_key [String] Feature flag key
94
+ # @param fallback_variant [SelectedVariant] Fallback variant
95
+ # @param context [Hash] Evaluation context
96
+ # @param report_exposure [Boolean] Whether to track exposure event
97
+ # @return [SelectedVariant]
98
+ def get_variant(flag_key, fallback_variant, context, report_exposure: true)
99
+ flag = @flag_definitions[flag_key]
100
+
101
+ return fallback_variant unless flag
102
+
103
+ context_key = flag['context']
104
+ unless context.key?(context_key) || context.key?(context_key.to_sym)
105
+ return fallback_variant
106
+ end
107
+
108
+ context_value = context[context_key] || context[context_key.to_sym]
109
+
110
+ selected_variant = nil
111
+
112
+ test_variant = get_variant_override_for_test_user(flag, context)
113
+ if test_variant
114
+ selected_variant = test_variant
115
+ else
116
+ rollout = get_assigned_rollout(flag, context_value, context)
117
+ if rollout
118
+ selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout)
119
+ end
120
+ end
121
+
122
+ if selected_variant
123
+ track_exposure_event(flag_key, selected_variant, context) if report_exposure
124
+ return selected_variant
125
+ end
126
+
127
+ fallback_variant
128
+ end
129
+
130
+ # Get all variants for user context
131
+ # Exposure events NOT tracked automatically
132
+ # @param context [Hash] Evaluation context
133
+ # @return [Hash] Map of flag_key => SelectedVariant
134
+ def get_all_variants(context)
135
+ variants = {}
136
+
137
+ @flag_definitions.each_key do |flag_key|
138
+ variant = get_variant(flag_key, nil, context, report_exposure: false)
139
+ variants[flag_key] = variant if variant
140
+ end
141
+
142
+ variants
143
+ end
144
+
145
+ private
146
+
147
+ def fetch_flag_definitions
148
+ response = call_flags_endpoint
149
+
150
+ new_definitions = {}
151
+ (response['flags'] || []).each do |flag_data|
152
+ new_definitions[flag_data['key']] = flag_data
153
+ end
154
+
155
+ @flag_definitions = new_definitions
156
+
157
+ response
158
+ end
159
+
160
+ def get_variant_override_for_test_user(flag, context)
161
+ test_users = flag.dig('ruleset', 'test', 'users')
162
+ return nil unless test_users
163
+
164
+ distinct_id = context['distinct_id'] || context[:distinct_id]
165
+ return nil unless distinct_id
166
+
167
+ variant_key = test_users[distinct_id.to_s]
168
+ return nil unless variant_key
169
+
170
+ variant = get_matching_variant(variant_key, flag)
171
+ if variant
172
+ variant.is_qa_tester = true
173
+ end
174
+ variant
175
+ end
176
+
177
+ def get_matching_variant(variant_key, flag)
178
+ return nil unless flag['ruleset'] && flag['ruleset']['variants']
179
+
180
+ flag['ruleset']['variants'].each do |v|
181
+ if variant_key.downcase == v['key'].downcase
182
+ return SelectedVariant.new(
183
+ variant_key: v['key'],
184
+ variant_value: v['value'],
185
+ experiment_id: flag['experiment_id'],
186
+ is_experiment_active: flag['is_experiment_active']
187
+ )
188
+ end
189
+ end
190
+ nil
191
+ end
192
+
193
+ def get_assigned_rollout(flag, context_value, context)
194
+ return nil unless flag['ruleset'] && flag['ruleset']['rollout']
195
+
196
+ flag['ruleset']['rollout'].each_with_index do |rollout, index|
197
+ salt = if flag['hash_salt']
198
+ "#{flag['key']}#{flag['hash_salt']}#{index}"
199
+ else
200
+ "#{flag['key']}rollout"
201
+ end
202
+
203
+ rollout_hash = Utils.normalized_hash(context_value.to_s, salt)
204
+
205
+ if rollout_hash < rollout['rollout_percentage'] &&
206
+ is_runtime_evaluation_satisfied?(rollout, context)
207
+ return rollout
208
+ end
209
+ end
210
+
211
+ nil
212
+ end
213
+
214
+ def get_assigned_variant(flag, context_value, flag_key, rollout)
215
+ if rollout['variant_override']
216
+ variant = get_matching_variant(rollout['variant_override']['key'], flag)
217
+ if variant
218
+ variant.is_qa_tester = false
219
+ return variant
220
+ end
221
+ end
222
+
223
+ stored_salt = flag['hash_salt'] || ''
224
+ salt = "#{flag_key}#{stored_salt}variant"
225
+ variant_hash = Utils.normalized_hash(context_value.to_s, salt)
226
+
227
+ variants = flag['ruleset']['variants'].map { |v| v.dup }
228
+ if rollout['variant_splits']
229
+ variants.each do |v|
230
+ v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key'])
231
+ end
232
+ end
233
+
234
+ selected = variants.first
235
+ cumulative = 0.0
236
+ variants.each do |v|
237
+ selected = v
238
+ cumulative += (v['split'] || 0.0)
239
+ break if variant_hash < cumulative
240
+ end
241
+
242
+ SelectedVariant.new(
243
+ variant_key: selected['key'],
244
+ variant_value: selected['value'],
245
+ experiment_id: flag['experiment_id'],
246
+ is_experiment_active: flag['is_experiment_active'],
247
+ is_qa_tester: false
248
+ )
249
+ end
250
+
251
+ def lowercase_keys_and_values(val)
252
+ case val
253
+ when String
254
+ val.downcase
255
+ when Array
256
+ val.map { |item| lowercase_keys_and_values(item) }
257
+ when Hash
258
+ val.transform_keys { |k| k.is_a?(String) ? k.downcase : k }
259
+ .transform_values { |v| lowercase_keys_and_values(v) }
260
+ else
261
+ val
262
+ end
263
+ end
264
+
265
+ def lowercase_only_leaf_nodes(val)
266
+ case val
267
+ when String
268
+ val.downcase
269
+ when Array
270
+ val.map { |item| lowercase_only_leaf_nodes(item) }
271
+ when Hash
272
+ val.transform_values { |v| lowercase_only_leaf_nodes(v) }
273
+ else
274
+ val
275
+ end
276
+ end
277
+
278
+ def get_runtime_parameters(context)
279
+ custom_props = context['custom_properties'] || context[:custom_properties]
280
+ return nil unless custom_props && custom_props.is_a?(Hash)
281
+
282
+ lowercase_keys_and_values(custom_props)
283
+ end
284
+
285
+ def is_runtime_evaluation_satisfied?(rollout, context)
286
+ runtime_rule = rollout['runtime_evaluation_rule']
287
+ return true unless runtime_rule
288
+
289
+ parameters = get_runtime_parameters(context)
290
+ return false unless parameters
291
+
292
+ begin
293
+ rule = lowercase_only_leaf_nodes(runtime_rule)
294
+ result = JsonLogic.apply(rule, parameters)
295
+ !!result
296
+ rescue StandardError => e
297
+ @error_handler.handle(e) if @error_handler
298
+ false
299
+ end
300
+ end
301
+ end
302
+ end
303
+ end
@@ -0,0 +1,134 @@
1
+ require 'mixpanel-ruby/flags/flags_provider'
2
+
3
+ module Mixpanel
4
+ module Flags
5
+ # Remote feature flags provider
6
+ # Evaluates flags on the server-side via HTTP API calls
7
+ class RemoteFlagsProvider < FlagsProvider
8
+ DEFAULT_CONFIG = {
9
+ api_host: 'api.mixpanel.com',
10
+ request_timeout_in_seconds: 10
11
+ }.freeze
12
+
13
+ # @param token [String] Mixpanel project token
14
+ # @param config [Hash] Remote flags configuration
15
+ # @param tracker_callback [Proc] Callback to track events
16
+ # @param error_handler [Mixpanel::ErrorHandler] Error handler
17
+ def initialize(token, config, tracker_callback, error_handler)
18
+ merged_config = DEFAULT_CONFIG.merge(config || {})
19
+
20
+ provider_config = {
21
+ token: token,
22
+ api_host: merged_config[:api_host],
23
+ request_timeout_in_seconds: merged_config[:request_timeout_in_seconds]
24
+ }
25
+
26
+ super(provider_config, '/flags', tracker_callback, 'remote', error_handler)
27
+ end
28
+
29
+ # Get variant value for a flag
30
+ # @param flag_key [String] Feature flag key
31
+ # @param fallback_value [Object] Fallback value
32
+ # @param context [Hash] Evaluation context
33
+ # @param report_exposure [Boolean] Whether to track exposure
34
+ # @return [Object] Variant value
35
+ def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
36
+ selected_variant = get_variant(
37
+ flag_key,
38
+ SelectedVariant.new(variant_value: fallback_value),
39
+ context,
40
+ report_exposure: report_exposure
41
+ )
42
+ selected_variant.variant_value
43
+ rescue MixpanelError => e
44
+ @error_handler.handle(e)
45
+ fallback_value
46
+ end
47
+
48
+ # Get complete variant information
49
+ # @param flag_key [String] Feature flag key
50
+ # @param fallback_variant [SelectedVariant] Fallback variant
51
+ # @param context [Hash] Evaluation context
52
+ # @param report_exposure [Boolean] Whether to track exposure
53
+ # @return [SelectedVariant]
54
+ def get_variant(flag_key, fallback_variant, context, report_exposure: true)
55
+ start_time = Time.now
56
+ response = fetch_flags(context, flag_key)
57
+ latency_ms = ((Time.now - start_time) * 1000).to_i
58
+
59
+ flags = response['flags'] || {}
60
+ selected_variant_data = flags[flag_key]
61
+
62
+ return fallback_variant unless selected_variant_data
63
+
64
+ selected_variant = SelectedVariant.new(
65
+ variant_key: selected_variant_data['variant_key'],
66
+ variant_value: selected_variant_data['variant_value'],
67
+ experiment_id: selected_variant_data['experiment_id'],
68
+ is_experiment_active: selected_variant_data['is_experiment_active']
69
+ )
70
+
71
+ track_exposure_event(flag_key, selected_variant, context, latency_ms) if report_exposure
72
+
73
+ return selected_variant
74
+ rescue MixpanelError => e
75
+ @error_handler.handle(e)
76
+ return fallback_variant
77
+ end
78
+
79
+ # Check if flag is enabled (for boolean flags)
80
+ # This method is intended only for flags defined as Mixpanel Feature Gates (boolean flags)
81
+ # This checks that the variant value of a selected variant is concretely the boolean 'true'
82
+ # It does not coerce other truthy values.
83
+ # @param flag_key [String] Feature flag key
84
+ # @param context [Hash] Evaluation context
85
+ # @return [Boolean]
86
+ def is_enabled?(flag_key, context)
87
+ value = get_variant_value(flag_key, false, context)
88
+ value == true
89
+ rescue MixpanelError => e
90
+ @error_handler.handle(e)
91
+ false
92
+ end
93
+
94
+ # Get all variants for user context
95
+ # Exposure events NOT tracked automatically
96
+ # @param context [Hash] Evaluation context
97
+ # @return [Hash, nil] Map of flag_key => SelectedVariant, or nil on error
98
+ def get_all_variants(context)
99
+ response = fetch_flags(context)
100
+
101
+ variants = {}
102
+ (response['flags'] || {}).each do |flag_key, variant_data|
103
+ variants[flag_key] = SelectedVariant.new(
104
+ variant_key: variant_data['variant_key'],
105
+ variant_value: variant_data['variant_value'],
106
+ experiment_id: variant_data['experiment_id'],
107
+ is_experiment_active: variant_data['is_experiment_active']
108
+ )
109
+ end
110
+
111
+ variants
112
+ rescue MixpanelError => e
113
+ @error_handler.handle(e)
114
+ nil
115
+ end
116
+
117
+ private
118
+
119
+ # Fetch flags from remote API
120
+ # @param context [Hash] Evaluation context
121
+ # @param flag_key [String, nil] Optional specific flag key
122
+ # @return [Hash] API response
123
+ def fetch_flags(context, flag_key = nil)
124
+ additional_params = {
125
+ 'context' => JSON.generate(context)
126
+ }
127
+
128
+ additional_params['flag_key'] = flag_key if flag_key
129
+
130
+ call_flags_endpoint(additional_params)
131
+ end
132
+ end
133
+ end
134
+ end