mixpanel-ruby 2.3.0 → 3.1.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +66 -0
  3. data/Readme.rdoc +8 -2
  4. data/demo/flags/local_flags.rb +25 -0
  5. data/demo/flags/remote_flags.rb +18 -0
  6. data/lib/mixpanel-ruby/events.rb +2 -2
  7. data/lib/mixpanel-ruby/flags/flags_provider.rb +115 -0
  8. data/lib/mixpanel-ruby/flags/local_flags_provider.rb +300 -0
  9. data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +134 -0
  10. data/lib/mixpanel-ruby/flags/types.rb +35 -0
  11. data/lib/mixpanel-ruby/flags/utils.rb +65 -0
  12. data/lib/mixpanel-ruby/groups.rb +1 -1
  13. data/lib/mixpanel-ruby/people.rb +1 -1
  14. data/lib/mixpanel-ruby/tracker.rb +32 -2
  15. data/lib/mixpanel-ruby/version.rb +1 -1
  16. data/lib/mixpanel-ruby.rb +5 -0
  17. data/mixpanel-ruby.gemspec +10 -3
  18. data/openfeature-provider/Gemfile +7 -0
  19. data/openfeature-provider/README.md +286 -0
  20. data/openfeature-provider/RELEASE.md +52 -0
  21. data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
  22. data/openfeature-provider/lib/mixpanel/openfeature.rb +3 -0
  23. data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +23 -0
  24. data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
  25. data/openfeature-provider/spec/spec_helper.rb +23 -0
  26. data/spec/mixpanel-ruby/events_spec.rb +2 -2
  27. data/spec/mixpanel-ruby/flags/local_flags_spec.rb +759 -0
  28. data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +441 -0
  29. data/spec/mixpanel-ruby/flags/utils_spec.rb +110 -0
  30. data/spec/mixpanel-ruby/groups_spec.rb +10 -10
  31. data/spec/mixpanel-ruby/tracker_spec.rb +5 -5
  32. data/spec/spec_helper.rb +14 -0
  33. metadata +125 -9
  34. data/.travis.yml +0 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a4bc0206903c9c7d7e61253d7cfe248e17f698a754dac067e3d6997ee44688a
4
- data.tar.gz: eda4910784b73583c7ff29fcbeaae86b1259e407d8cddf5c5c9ccb7ad9004661
3
+ metadata.gz: 6d3a5c579488b2cbe1c3b2c6e489070a0f438dca91b8e5f91d863709c517c978
4
+ data.tar.gz: '089600f43e3c29ef4f83d129dd1f8f58682ae041b1ffe776fad3ff3036140a5e'
5
5
  SHA512:
6
- metadata.gz: ba267b81907f663ef400b694973569c2e2e00c323e8e199bea82516d3552c73f2ffcc6c864c0fca2123f53a3e246218e60c64e58b51aab5ac96792dae8f76a13
7
- data.tar.gz: 541c0b8b4da498f37a749d76542fad417c776c83f4d583c8ae5bbbfbba9979103cfb634bb4a3d8ae6748b6b302ca546524edc80258423eb2e862d279328d07f1
6
+ metadata.gz: 81511ab17dfe9e72ffeb81a97752d82a581297dc002aa722ee50e2d12a5faa835f7dc198ea1be5b2b62cec7cb117fd8906177b427d8e8e9eddfab21f24c5d930
7
+ data.tar.gz: 97bac85d2cb9edd8dc4655f3657a42ff4a1c56fcb0f6fd983da8bb5b44baec1c684a63afd16f5612a02c545e53dd84d07853416f8aab79a0ddd00ac88dfbe2dd
@@ -0,0 +1,66 @@
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@75cd11691c0faa626561e295848008c8a7dddffe # v5
35
+ with:
36
+ token: ${{ secrets.CODECOV_TOKEN }}
37
+ slug: mixpanel/mixpanel-ruby
38
+
39
+ test-openfeature:
40
+
41
+ runs-on: ubuntu-latest
42
+ strategy:
43
+ matrix:
44
+ ruby-version:
45
+ - '3.1'
46
+ - '3.2'
47
+ - '3.3'
48
+ - '3.4'
49
+
50
+ steps:
51
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
52
+ - name: Set up Ruby
53
+ uses: ruby/setup-ruby@d697be2f83c6234b20877c3b5eac7a7f342f0d0c # v1.269.0
54
+ with:
55
+ ruby-version: ${{ matrix.ruby-version }}
56
+ - name: Install dependencies
57
+ run: cd openfeature-provider && bundle install
58
+ - name: Run OpenFeature provider tests
59
+ run: cd openfeature-provider && bundle exec rspec
60
+ - name: Upload coverage reports to Codecov
61
+ uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
62
+ with:
63
+ token: ${{ secrets.CODECOV_TOKEN }}
64
+ slug: mixpanel/mixpanel-ruby
65
+ flags: openfeature
66
+ directory: openfeature-provider
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,115 @@
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
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
64
+ raise ConnectionError.new("Request timeout: #{e.message}")
65
+ rescue StandardError => e
66
+ raise ConnectionError.new("Network error: #{e.message}")
67
+ end
68
+
69
+ unless response.code == '200'
70
+ raise ServerError.new("HTTP #{response.code}: #{response.body}")
71
+ end
72
+
73
+ begin
74
+ JSON.parse(response.body)
75
+ rescue JSON::ParserError => e
76
+ raise ServerError.new("Invalid JSON response: #{e.message}")
77
+ end
78
+ end
79
+
80
+ def shutdown; end
81
+
82
+ # Track exposure event to Mixpanel
83
+ # @param flag_key [String] Feature flag key
84
+ # @param selected_variant [SelectedVariant] The selected variant
85
+ # @param context [Hash] User context (must include 'distinct_id')
86
+ # @param latency_ms [Integer, nil] Optional latency in milliseconds
87
+ def track_exposure_event(flag_key, selected_variant, context, latency_ms = nil)
88
+ distinct_id = context['distinct_id'] || context[:distinct_id]
89
+
90
+ unless distinct_id
91
+ return
92
+ end
93
+
94
+ properties = {
95
+ 'distinct_id' => distinct_id,
96
+ 'Experiment name' => flag_key,
97
+ 'Variant name' => selected_variant.variant_key,
98
+ '$experiment_type' => 'feature_flag',
99
+ 'Flag evaluation mode' => @evaluation_mode
100
+ }
101
+
102
+ properties['Variant fetch latency (ms)'] = latency_ms if latency_ms
103
+ properties['$experiment_id'] = selected_variant.experiment_id if selected_variant.experiment_id
104
+ properties['$is_experiment_active'] = selected_variant.is_experiment_active unless selected_variant.is_experiment_active.nil?
105
+ properties['$is_qa_tester'] = selected_variant.is_qa_tester unless selected_variant.is_qa_tester.nil?
106
+
107
+ begin
108
+ @tracker_callback.call(distinct_id, Utils::EXPOSURE_EVENT, properties)
109
+ rescue MixpanelError => e
110
+ @error_handler.handle(e)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,300 @@
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
+ def shutdown
68
+ stop_polling_for_definitions!
69
+ end
70
+
71
+ # Check if flag is enabled (for boolean flags)
72
+ # @param flag_key [String] Feature flag key
73
+ # @param context [Hash] Evaluation context (must include 'distinct_id')
74
+ # @return [Boolean]
75
+ def is_enabled?(flag_key, context)
76
+ value = get_variant_value(flag_key, false, context)
77
+ value == true
78
+ end
79
+
80
+ # Get variant value for a flag
81
+ # @param flag_key [String] Feature flag key
82
+ # @param fallback_value [Object] Fallback value if not in rollout
83
+ # @param context [Hash] Evaluation context
84
+ # @param report_exposure [Boolean] Whether to track exposure event
85
+ # @return [Object] The variant value
86
+ def get_variant_value(flag_key, fallback_value, context, report_exposure: true)
87
+ result = get_variant(
88
+ flag_key,
89
+ SelectedVariant.new(variant_value: fallback_value),
90
+ context,
91
+ report_exposure: report_exposure
92
+ )
93
+ result.variant_value
94
+ end
95
+
96
+ # Get complete variant information
97
+ # @param flag_key [String] Feature flag key
98
+ # @param fallback_variant [SelectedVariant] Fallback variant
99
+ # @param context [Hash] Evaluation context
100
+ # @param report_exposure [Boolean] Whether to track exposure event
101
+ # @return [SelectedVariant]
102
+ def get_variant(flag_key, fallback_variant, context, report_exposure: true)
103
+ flag = @flag_definitions[flag_key]
104
+
105
+ return fallback_variant unless flag
106
+
107
+ context_key = flag['context']
108
+ unless context.key?(context_key) || context.key?(context_key.to_sym)
109
+ return fallback_variant
110
+ end
111
+
112
+ context_value = context[context_key] || context[context_key.to_sym]
113
+
114
+ selected_variant = get_variant_override_for_test_user(flag, context)
115
+
116
+ unless selected_variant
117
+ rollout = get_assigned_rollout(flag, context_value, context)
118
+ selected_variant = get_assigned_variant(flag, context_value, flag_key, rollout) if rollout
119
+ end
120
+
121
+ return fallback_variant unless selected_variant
122
+
123
+ track_exposure_event(flag_key, selected_variant, context) if report_exposure
124
+ selected_variant
125
+ end
126
+
127
+ # Get all variants for user context
128
+ # Exposure events NOT tracked automatically
129
+ # @param context [Hash] Evaluation context
130
+ # @return [Hash] Map of flag_key => SelectedVariant
131
+ def get_all_variants(context)
132
+ variants = {}
133
+
134
+ @flag_definitions.each_key do |flag_key|
135
+ variant = get_variant(flag_key, nil, context, report_exposure: false)
136
+ variants[flag_key] = variant if variant
137
+ end
138
+
139
+ variants
140
+ end
141
+
142
+ private
143
+
144
+ def fetch_flag_definitions
145
+ response = call_flags_endpoint
146
+
147
+ new_definitions = (response['flags'] || []).each_with_object({}) do |flag_data, definitions|
148
+ definitions[flag_data['key']] = flag_data
149
+ end
150
+ @flag_definitions = new_definitions
151
+
152
+ response
153
+ end
154
+
155
+ def get_variant_override_for_test_user(flag, context)
156
+ test_users = flag.dig('ruleset', 'test', 'users')
157
+ return nil unless test_users
158
+
159
+ distinct_id = context['distinct_id'] || context[:distinct_id]
160
+ return nil unless distinct_id
161
+
162
+ variant_key = test_users[distinct_id.to_s]
163
+ return nil unless variant_key
164
+
165
+ variant = get_matching_variant(variant_key, flag)
166
+ if variant
167
+ variant.is_qa_tester = true
168
+ end
169
+ variant
170
+ end
171
+
172
+ def get_matching_variant(variant_key, flag)
173
+ variants = flag.dig('ruleset', 'variants')
174
+ return nil unless variants
175
+
176
+ variants.each do |v|
177
+ if variant_key.downcase == v['key'].downcase
178
+ return SelectedVariant.new(
179
+ variant_key: v['key'],
180
+ variant_value: v['value'],
181
+ experiment_id: flag['experiment_id'],
182
+ is_experiment_active: flag['is_experiment_active']
183
+ )
184
+ end
185
+ end
186
+ nil
187
+ end
188
+
189
+ def get_assigned_rollout(flag, context_value, context)
190
+ rollouts = flag.dig('ruleset', 'rollout')
191
+ return nil unless rollouts
192
+
193
+ rollouts.each_with_index do |rollout, index|
194
+ salt = if flag['hash_salt']
195
+ "#{flag['key']}#{flag['hash_salt']}#{index}"
196
+ else
197
+ "#{flag['key']}rollout"
198
+ end
199
+
200
+ rollout_hash = Utils.normalized_hash(context_value.to_s, salt)
201
+
202
+ if rollout_hash < rollout['rollout_percentage'] &&
203
+ is_runtime_evaluation_satisfied?(rollout, context)
204
+ return rollout
205
+ end
206
+ end
207
+
208
+ nil
209
+ end
210
+
211
+ def get_assigned_variant(flag, context_value, flag_key, rollout)
212
+ if rollout['variant_override']
213
+ variant = get_matching_variant(rollout['variant_override']['key'], flag)
214
+ if variant
215
+ variant.is_qa_tester = false
216
+ return variant
217
+ end
218
+ end
219
+
220
+ stored_salt = flag['hash_salt'] || ''
221
+ salt = "#{flag_key}#{stored_salt}variant"
222
+ variant_hash = Utils.normalized_hash(context_value.to_s, salt)
223
+
224
+ variants = flag['ruleset']['variants'].map(&:dup)
225
+ if rollout['variant_splits']
226
+ variants.each do |v|
227
+ v['split'] = rollout['variant_splits'][v['key']] if rollout['variant_splits'].key?(v['key'])
228
+ end
229
+ end
230
+
231
+ selected = variants.first
232
+ cumulative = 0.0
233
+ variants.each do |v|
234
+ selected = v
235
+ cumulative += (v['split'] || 0.0)
236
+ break if variant_hash < cumulative
237
+ end
238
+
239
+ SelectedVariant.new(
240
+ variant_key: selected['key'],
241
+ variant_value: selected['value'],
242
+ experiment_id: flag['experiment_id'],
243
+ is_experiment_active: flag['is_experiment_active'],
244
+ is_qa_tester: false
245
+ )
246
+ end
247
+
248
+ def lowercase_keys_and_values(val)
249
+ case val
250
+ when String
251
+ val.downcase
252
+ when Array
253
+ val.map { |item| lowercase_keys_and_values(item) }
254
+ when Hash
255
+ val.transform_keys { |k| k.is_a?(String) ? k.downcase : k }
256
+ .transform_values { |v| lowercase_keys_and_values(v) }
257
+ else
258
+ val
259
+ end
260
+ end
261
+
262
+ def lowercase_only_leaf_nodes(val)
263
+ case val
264
+ when String
265
+ val.downcase
266
+ when Array
267
+ val.map { |item| lowercase_only_leaf_nodes(item) }
268
+ when Hash
269
+ val.transform_values { |v| lowercase_only_leaf_nodes(v) }
270
+ else
271
+ val
272
+ end
273
+ end
274
+
275
+ def get_runtime_parameters(context)
276
+ custom_props = context['custom_properties'] || context[:custom_properties]
277
+ return nil unless custom_props && custom_props.is_a?(Hash)
278
+
279
+ lowercase_keys_and_values(custom_props)
280
+ end
281
+
282
+ def is_runtime_evaluation_satisfied?(rollout, context)
283
+ runtime_rule = rollout['runtime_evaluation_rule']
284
+ return true unless runtime_rule
285
+
286
+ parameters = get_runtime_parameters(context)
287
+ return false unless parameters
288
+
289
+ begin
290
+ rule = lowercase_only_leaf_nodes(runtime_rule)
291
+ result = JsonLogic.apply(rule, parameters)
292
+ !!result
293
+ rescue StandardError => e
294
+ @error_handler.handle(e) if @error_handler
295
+ false
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end