txlemetry-ruby 4.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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +48 -0
  3. data/NOTICE +5 -0
  4. data/bin/txlemetry +110 -0
  5. data/lib/txlemetry/backoff_policy.rb +49 -0
  6. data/lib/txlemetry/client.rb +1266 -0
  7. data/lib/txlemetry/defaults.rb +50 -0
  8. data/lib/txlemetry/exception_capture.rb +246 -0
  9. data/lib/txlemetry/feature_flag.rb +85 -0
  10. data/lib/txlemetry/feature_flag_error.rb +37 -0
  11. data/lib/txlemetry/feature_flag_evaluations.rb +237 -0
  12. data/lib/txlemetry/feature_flag_result.rb +81 -0
  13. data/lib/txlemetry/feature_flags.rb +1516 -0
  14. data/lib/txlemetry/field_parser.rb +219 -0
  15. data/lib/txlemetry/flag_definition_cache.rb +88 -0
  16. data/lib/txlemetry/internal/context.rb +119 -0
  17. data/lib/txlemetry/logging.rb +77 -0
  18. data/lib/txlemetry/mcp/README.md +53 -0
  19. data/lib/txlemetry/mcp/analytics.rb +116 -0
  20. data/lib/txlemetry/mcp/client.rb +257 -0
  21. data/lib/txlemetry/mcp/constants.rb +111 -0
  22. data/lib/txlemetry/mcp/conversation_id.rb +89 -0
  23. data/lib/txlemetry/mcp/event_builder.rb +168 -0
  24. data/lib/txlemetry/mcp/exceptions.rb +112 -0
  25. data/lib/txlemetry/mcp/identity.rb +194 -0
  26. data/lib/txlemetry/mcp/ids.rb +56 -0
  27. data/lib/txlemetry/mcp/instrumentation.rb +736 -0
  28. data/lib/txlemetry/mcp/intent.rb +101 -0
  29. data/lib/txlemetry/mcp/log.rb +37 -0
  30. data/lib/txlemetry/mcp/options.rb +128 -0
  31. data/lib/txlemetry/mcp/rack_middleware.rb +112 -0
  32. data/lib/txlemetry/mcp/request_scope.rb +72 -0
  33. data/lib/txlemetry/mcp/sanitization.rb +504 -0
  34. data/lib/txlemetry/mcp/schema_mutation.rb +179 -0
  35. data/lib/txlemetry/mcp/server_extension.rb +54 -0
  36. data/lib/txlemetry/mcp/session.rb +71 -0
  37. data/lib/txlemetry/mcp/session_token.rb +106 -0
  38. data/lib/txlemetry/mcp/sink.rb +98 -0
  39. data/lib/txlemetry/mcp/tools.rb +91 -0
  40. data/lib/txlemetry/mcp/tracking_data.rb +89 -0
  41. data/lib/txlemetry/mcp/truncation.rb +326 -0
  42. data/lib/txlemetry/mcp.rb +224 -0
  43. data/lib/txlemetry/message_batch.rb +80 -0
  44. data/lib/txlemetry/noop_worker.rb +39 -0
  45. data/lib/txlemetry/response.rb +17 -0
  46. data/lib/txlemetry/send_feature_flags_options.rb +49 -0
  47. data/lib/txlemetry/send_worker.rb +218 -0
  48. data/lib/txlemetry/transport.rb +216 -0
  49. data/lib/txlemetry/utils.rb +183 -0
  50. data/lib/txlemetry/version.rb +5 -0
  51. data/lib/txlemetry.rb +16 -0
  52. metadata +105 -0
@@ -0,0 +1,1516 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'txlemetry/version'
7
+ require 'txlemetry/logging'
8
+ require 'txlemetry/defaults'
9
+ require 'txlemetry/backoff_policy'
10
+ require 'txlemetry/feature_flag'
11
+ require 'txlemetry/flag_definition_cache'
12
+ require 'digest'
13
+
14
+ module Txlemetry
15
+ class InconclusiveMatchError < StandardError
16
+ end
17
+
18
+ # RequiresServerEvaluation is raised when feature flag evaluation requires
19
+ # server-side data that is not available locally (e.g., static cohorts,
20
+ # experience continuity). This error should propagate immediately to trigger
21
+ # API fallback, unlike InconclusiveMatchError which allows trying other conditions.
22
+ class RequiresServerEvaluation < StandardError
23
+ end
24
+
25
+ # Polls and evaluates feature flag definitions for {Txlemetry::Client}.
26
+ #
27
+ # @api private
28
+ class FeatureFlagsPoller
29
+ include Txlemetry::Logging
30
+ include Txlemetry::Utils
31
+
32
+ # @param polling_interval [Integer, nil] Seconds between local feature flag definition polls.
33
+ # Defaults to {Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS}.
34
+ # @param secret_key [String, nil] Credential used to fetch local evaluation definitions. Accepts either a
35
+ # Personal API Key (`phx_...`) or a Project Secret API Key (`phs_...`).
36
+ # @param project_api_key [String] Project API key.
37
+ # @param host [String] Txlemetry API host URL.
38
+ # @param feature_flag_request_timeout_seconds [Integer] Timeout for feature flag requests.
39
+ # @param on_error [Proc, nil] Callback invoked as `on_error.call(status, error)`.
40
+ # @param flag_definition_cache_provider [Object, nil] Optional {FlagDefinitionCacheProvider} implementation.
41
+ # @param feature_flag_request_max_retries [Integer, nil] Retries after a transient network error or retryable
42
+ # HTTP response status on a flag request. Defaults to {Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES}.
43
+ # Set to 0 to disable retrying.
44
+ # @param async_load [Boolean] When true, flag definitions are fetched only on the poller thread: an
45
+ # immediate first tick at construction, then the regular polling cadence, which keeps retrying until a
46
+ # load succeeds.
47
+ # @param user_agent [String] User-Agent header sent with feature flag requests.
48
+ # @param flag_definitions_update_mutex [Mutex] Internal lock shared with flag-called deduplication.
49
+ # @param on_flag_definitions_updated [Proc, nil] Internal callback after definitions are applied or discarded,
50
+ # called while holding flag_definitions_update_mutex.
51
+ def initialize(
52
+ polling_interval,
53
+ secret_key,
54
+ project_api_key,
55
+ host,
56
+ feature_flag_request_timeout_seconds,
57
+ on_error = nil,
58
+ flag_definition_cache_provider: nil,
59
+ feature_flag_request_max_retries: nil,
60
+ async_load: false,
61
+ user_agent: "txlemetry-ruby/#{Txlemetry::VERSION}",
62
+ on_flag_definitions_updated: nil,
63
+ flag_definitions_update_mutex: Mutex.new
64
+ )
65
+ @polling_interval = polling_interval || Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS
66
+ @secret_key = secret_key
67
+ @project_api_key = project_api_key
68
+ @host = host
69
+ @feature_flags = Concurrent::Array.new
70
+ @group_type_mapping = Concurrent::Hash.new
71
+ @cohorts = Concurrent::Hash.new
72
+ @loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
73
+ @feature_flags_by_key = nil
74
+ @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
75
+ @feature_flag_request_max_retries =
76
+ feature_flag_request_max_retries || Defaults::FeatureFlags::FLAG_REQUEST_MAX_RETRIES
77
+ @on_error = on_error || proc { |status, error| }
78
+ @quota_limited = Concurrent::AtomicBoolean.new(false)
79
+ @flags_etag = Concurrent::AtomicReference.new(nil)
80
+ @flag_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
81
+ @async_load = async_load
82
+ @user_agent = user_agent
83
+ @on_flag_definitions_updated = on_flag_definitions_updated
84
+ @flag_definitions_update_mutex = flag_definitions_update_mutex
85
+ # Server-controlled gate for minimal `$feature_flag_called` events, read
86
+ # from the top-level `minimal_flag_called_events` key of the local
87
+ # evaluation definitions payload. false when the server does not send it.
88
+ @minimal_flag_called_events = false
89
+ @definition_snapshot = Concurrent::AtomicReference.new({
90
+ flags: @feature_flags, flags_by_key: @feature_flags_by_key,
91
+ group_type_mapping: @group_type_mapping, cohorts: @cohorts,
92
+ minimal_flag_called_events: @minimal_flag_called_events, property_matching_version: 1
93
+ }.freeze)
94
+ @flag_definition_cache_provider = flag_definition_cache_provider
95
+ FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
96
+
97
+ @task =
98
+ Concurrent::TimerTask.new(
99
+ execution_interval: @polling_interval,
100
+ run_now: @async_load
101
+ ) { _load_feature_flags }
102
+
103
+ # If no secret_key, disable local evaluation & thus polling for definitions
104
+ if @secret_key.nil?
105
+ logger.info 'No secret_key provided, disabling local evaluation'
106
+ @loaded_flags_successfully_once.make_true
107
+ else
108
+ # load once synchronously before timer, unless @async_load
109
+ load_feature_flags unless @async_load
110
+ @task.execute
111
+ end
112
+ end
113
+
114
+ def load_feature_flags(force_reload = false)
115
+ return if (@loaded_flags_successfully_once.true? || @async_load) && !force_reload
116
+
117
+ _load_feature_flags
118
+ end
119
+
120
+ # Whether flag definitions are currently loaded. False until the first
121
+ # successful load, and false again if a quota-limited (402) response
122
+ # discards them.
123
+ def definitions_loaded?
124
+ !@flag_definitions_loaded_at.value.nil?
125
+ end
126
+
127
+ # Epoch milliseconds of the last successful definitions load, or nil.
128
+ def flag_definitions_loaded_at
129
+ @flag_definitions_loaded_at.value
130
+ end
131
+
132
+ attr_reader :feature_flags_by_key, :minimal_flag_called_events
133
+
134
+ def get_feature_variants(
135
+ distinct_id,
136
+ groups = {},
137
+ person_properties = {},
138
+ group_properties = {},
139
+ only_evaluate_locally = false,
140
+ raise_on_error = false
141
+ )
142
+ # TODO: Convert to options hash for easier argument passing
143
+ flags_data = get_all_flags_and_payloads(
144
+ distinct_id,
145
+ groups,
146
+ person_properties,
147
+ group_properties,
148
+ only_evaluate_locally,
149
+ raise_on_error
150
+ )
151
+
152
+ if flags_data.key?(:featureFlags)
153
+ stringify_keys(flags_data[:featureFlags] || {})
154
+ else
155
+ logger.debug "Missing feature flags key: #{flags_data.to_json}"
156
+ {}
157
+ end
158
+ end
159
+
160
+ def get_feature_payloads(
161
+ distinct_id,
162
+ groups = {},
163
+ person_properties = {},
164
+ group_properties = {},
165
+ _only_evaluate_locally = false
166
+ )
167
+ flags_data = get_all_flags_and_payloads(
168
+ distinct_id,
169
+ groups,
170
+ person_properties,
171
+ group_properties
172
+ )
173
+
174
+ if flags_data.key?(:featureFlagPayloads)
175
+ stringify_keys(flags_data[:featureFlagPayloads] || {})
176
+ else
177
+ logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
178
+ {}
179
+ end
180
+ end
181
+
182
+ def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, flag_keys = nil,
183
+ disable_geoip = nil)
184
+ flag_keys = flag_keys.map(&:to_s) if flag_keys.is_a?(Array)
185
+ request_data = {
186
+ distinct_id: distinct_id,
187
+ groups: groups,
188
+ person_properties: person_properties,
189
+ group_properties: group_properties
190
+ }
191
+ request_data[:flag_keys_to_evaluate] = flag_keys if flag_keys && !flag_keys.empty?
192
+ request_data[:geoip_disable] = disable_geoip unless disable_geoip.nil?
193
+
194
+ flags_response = _request_feature_flag_evaluation(request_data)
195
+
196
+ # Only normalize if we have flags in the response
197
+ if flags_response[:flags]
198
+ # v4 format
199
+ flags_hash = flags_response[:flags].transform_values do |flag|
200
+ FeatureFlag.new(flag)
201
+ end
202
+ flags_response[:flags] = flags_hash
203
+ # Filter out flags that failed evaluation to avoid overwriting cached values
204
+ successful_flags = flags_hash.reject { |_, flag| flag.failed == true }
205
+ flags_response[:featureFlags] = successful_flags.transform_values(&:get_value).transform_keys(&:to_sym)
206
+ flags_response[:featureFlagPayloads] = successful_flags.transform_values(&:payload).transform_keys(&:to_sym)
207
+ elsif flags_response[:featureFlags]
208
+ # v3 format
209
+ flags_response[:featureFlags] = flags_response[:featureFlags] || {}
210
+ flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
211
+ flags_response[:flags] = flags_response[:featureFlags].to_h do |key, value|
212
+ [key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
213
+ end
214
+ end
215
+
216
+ flags_response
217
+ end
218
+
219
+ def get_remote_config_payload(flag_key)
220
+ _request_remote_config_payload(flag_key.to_s)
221
+ end
222
+
223
+ def get_feature_flag(
224
+ key,
225
+ distinct_id,
226
+ groups = {},
227
+ person_properties = {},
228
+ group_properties = {},
229
+ only_evaluate_locally = false,
230
+ snapshot: nil
231
+ )
232
+ key = key.to_s
233
+
234
+ # make sure they're loaded on first run
235
+ load_feature_flags
236
+ snapshot ||= _evaluation_snapshot
237
+
238
+ symbolize_keys! groups
239
+ symbolize_keys! person_properties
240
+ symbolize_keys! group_properties
241
+
242
+ group_properties.each_value do |value|
243
+ symbolize_keys!(value)
244
+ end
245
+
246
+ response = nil
247
+ payload = nil
248
+ feature_flag = snapshot[:flags_by_key]&.[](key)
249
+
250
+ unless feature_flag.nil?
251
+ begin
252
+ response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties,
253
+ snapshot: snapshot)
254
+ payload = _compute_flag_payload_locally(key, response, snapshot: snapshot) unless response.nil?
255
+ logger.debug "Successfully computed flag locally: #{key} -> #{response}"
256
+ rescue RequiresServerEvaluation, InconclusiveMatchError => e
257
+ logger.debug "Failed to compute flag #{key} locally: #{e}"
258
+ rescue StandardError => e
259
+ @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
260
+ end
261
+ end
262
+
263
+ flag_was_locally_evaluated = !response.nil?
264
+ # Whether the flag is linked to an experiment, as reported by the server.
265
+ # Locally-evaluated flags carry it in the stored definition; remotely
266
+ # evaluated flags carry it in the response metadata. nil when the server
267
+ # (an older deployment) does not report it.
268
+ has_experiment = feature_flag[:has_experiment] if flag_was_locally_evaluated
269
+ # Server-controlled gate for minimal `$feature_flag_called` events.
270
+ # Locally-evaluated flags read it from the definitions payload; remotely
271
+ # evaluated flags read it from the /flags response. nil when the signal
272
+ # is unavailable, which fails safe to the full event.
273
+ minimal_flag_called_events = snapshot[:minimal_flag_called_events] if flag_was_locally_evaluated
274
+
275
+ request_id = nil
276
+ evaluated_at = nil
277
+ feature_flag_error = nil
278
+
279
+ if !flag_was_locally_evaluated && !only_evaluate_locally
280
+ begin
281
+ errors = []
282
+ flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
283
+ only_evaluate_locally, true)
284
+ if flags_data.key?(:featureFlags)
285
+ flags = stringify_keys(flags_data[:featureFlags] || {})
286
+ payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
287
+ request_id = flags_data[:requestId]
288
+ evaluated_at = flags_data[:evaluatedAt]
289
+ else
290
+ logger.debug "Missing feature flags key: #{flags_data.to_json}"
291
+ flags = {}
292
+ payloads = {}
293
+ end
294
+
295
+ status = flags_data[:status]
296
+ errors << FeatureFlagError.api_error(status) if status && status >= 400
297
+ errors << FeatureFlagError::ERRORS_WHILE_COMPUTING if flags_data[:errorsWhileComputingFlags]
298
+ errors << FeatureFlagError::QUOTA_LIMITED if flags_data[:quotaLimited]&.include?('feature_flags')
299
+ errors << FeatureFlagError::FLAG_MISSING unless flags.key?(key.to_s)
300
+
301
+ response = flags[key]
302
+ response = false if response.nil?
303
+ payload = payloads[key]
304
+ feature_flag_error = errors.join(',') unless errors.empty?
305
+
306
+ flag_detail = flags_data[:flagDetails]&.[](key.to_sym)
307
+ has_experiment = flag_detail&.metadata&.has_experiment
308
+ minimal_flag_called_events = flags_data[:minimalFlagCalledEvents]
309
+
310
+ logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
311
+ rescue Timeout::Error => e
312
+ @on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
313
+ feature_flag_error = FeatureFlagError::TIMEOUT
314
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, SocketError => e
315
+ @on_error.call(-1, "Connection error while fetching flags remotely: #{e}")
316
+ feature_flag_error = FeatureFlagError::CONNECTION_ERROR
317
+ rescue StandardError => e
318
+ @on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
319
+ feature_flag_error = FeatureFlagError::UNKNOWN_ERROR
320
+ end
321
+ end
322
+
323
+ [response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload, has_experiment,
324
+ minimal_flag_called_events]
325
+ end
326
+
327
+ def get_all_flags(
328
+ distinct_id,
329
+ groups = {},
330
+ person_properties = {},
331
+ group_properties = {},
332
+ only_evaluate_locally = false
333
+ )
334
+ if @quota_limited.true?
335
+ logger.debug 'Not fetching flags from server - quota limited'
336
+ return {}
337
+ end
338
+
339
+ # returns a string hash of all flags
340
+ response = get_all_flags_and_payloads(
341
+ distinct_id,
342
+ groups,
343
+ person_properties,
344
+ group_properties,
345
+ only_evaluate_locally
346
+ )
347
+
348
+ response[:featureFlags]
349
+ end
350
+
351
+ def get_all_flags_and_payloads(
352
+ distinct_id,
353
+ groups = {},
354
+ person_properties = {},
355
+ group_properties = {},
356
+ only_evaluate_locally = false,
357
+ raise_on_error = false
358
+ )
359
+ load_feature_flags
360
+ snapshot = _evaluation_snapshot
361
+
362
+ flags = {}
363
+ payloads = {}
364
+ fallback_to_server = snapshot[:flags].empty?
365
+ request_id = nil # Only for /flags requests
366
+ evaluated_at = nil # Only for /flags requests
367
+
368
+ snapshot[:flags].each do |flag|
369
+ match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties,
370
+ snapshot: snapshot)
371
+ flags[flag[:key]] = match_value
372
+ match_payload = _compute_flag_payload_locally(flag[:key], match_value, snapshot: snapshot)
373
+ payloads[flag[:key]] = match_payload if match_payload
374
+ rescue RequiresServerEvaluation, InconclusiveMatchError
375
+ fallback_to_server = true
376
+ rescue StandardError => e
377
+ @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
378
+ fallback_to_server = true
379
+ end
380
+
381
+ errors_while_computing = false
382
+ quota_limited = nil
383
+ status_code = nil
384
+ flag_details = nil
385
+ minimal_flag_called_events = nil
386
+
387
+ if fallback_to_server && !only_evaluate_locally
388
+ begin
389
+ flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
390
+ flag_details = flags_and_payloads[:flags]
391
+ errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
392
+ quota_limited = flags_and_payloads[:quotaLimited]
393
+ status_code = flags_and_payloads[:status]
394
+
395
+ unless flags_and_payloads.key?(:featureFlags)
396
+ raise StandardError, "Error flags response: #{flags_and_payloads}"
397
+ end
398
+
399
+ request_id = flags_and_payloads[:requestId]
400
+ evaluated_at = flags_and_payloads[:evaluatedAt]
401
+ minimal_flag_called_events = flags_and_payloads[:minimalFlagCalledEvents]
402
+
403
+ # Check if feature_flags are quota limited
404
+ if quota_limited&.include?('feature_flags')
405
+ logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
406
+ flags = {}
407
+ payloads = {}
408
+ else
409
+ server_flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
410
+ server_payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
411
+
412
+ if errors_while_computing
413
+ # Merge server results into locally-evaluated results so that flags which
414
+ # failed server-side evaluation (already filtered out by get_flags) don't
415
+ # overwrite valid locally-evaluated values.
416
+ flags = stringify_keys(flags).merge(server_flags)
417
+ payloads = stringify_keys(payloads).merge(server_payloads)
418
+ else
419
+ flags = server_flags
420
+ payloads = server_payloads
421
+ end
422
+ end
423
+ rescue StandardError => e
424
+ @on_error.call(-1, "Error computing flag remotely: #{e}")
425
+ raise if raise_on_error
426
+ end
427
+ end
428
+
429
+ {
430
+ featureFlags: flags,
431
+ featureFlagPayloads: payloads,
432
+ flagDetails: flag_details,
433
+ requestId: request_id,
434
+ evaluatedAt: evaluated_at,
435
+ errorsWhileComputingFlags: errors_while_computing,
436
+ quotaLimited: quota_limited,
437
+ status: status_code,
438
+ minimalFlagCalledEvents: minimal_flag_called_events
439
+ }
440
+ end
441
+
442
+ def get_feature_flag_payload(
443
+ key,
444
+ distinct_id,
445
+ match_value = nil,
446
+ groups = {},
447
+ person_properties = {},
448
+ group_properties = {},
449
+ only_evaluate_locally = false
450
+ )
451
+ key = key.to_s
452
+ load_feature_flags
453
+ snapshot = _evaluation_snapshot
454
+
455
+ if match_value.nil?
456
+ match_value = get_feature_flag(
457
+ key,
458
+ distinct_id,
459
+ groups,
460
+ person_properties,
461
+ group_properties,
462
+ true,
463
+ snapshot: snapshot
464
+ )[0]
465
+ end
466
+ response = nil
467
+ response = _compute_flag_payload_locally(key, match_value, snapshot: snapshot) unless match_value.nil?
468
+ if response.nil? && !only_evaluate_locally
469
+ flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
470
+ response = flags_payloads[key.downcase] || nil
471
+ end
472
+ response
473
+ end
474
+
475
+ def shutdown_poller
476
+ @task.shutdown
477
+ return unless @flag_definition_cache_provider
478
+
479
+ begin
480
+ @flag_definition_cache_provider.shutdown
481
+ rescue StandardError => e
482
+ logger.error("[FEATURE FLAGS] Cache provider shutdown error: #{e}")
483
+ end
484
+ end
485
+
486
+ # Class methods
487
+
488
+ def self.compare(lhs, rhs, operator)
489
+ case operator
490
+ when 'gt'
491
+ lhs > rhs
492
+ when 'gte'
493
+ lhs >= rhs
494
+ when 'lt'
495
+ lhs < rhs
496
+ when 'lte'
497
+ lhs <= rhs
498
+ else
499
+ raise "Invalid operator: #{operator}"
500
+ end
501
+ end
502
+
503
+ def self.relative_date_parse_for_feature_flag_matching(value)
504
+ match = /^-?([0-9]+)([a-z])$/.match(value)
505
+ parsed_dt = DateTime.now.new_offset(0)
506
+ return unless match
507
+
508
+ number = match[1].to_i
509
+
510
+ if number >= 10_000
511
+ # Guard against overflow, disallow numbers greater than 10_000
512
+ return nil
513
+ end
514
+
515
+ interval = match[2]
516
+ case interval
517
+ when 'h'
518
+ parsed_dt -= (number / 24.0)
519
+ when 'd'
520
+ parsed_dt = parsed_dt.prev_day(number)
521
+ when 'w'
522
+ parsed_dt = parsed_dt.prev_day(number * 7)
523
+ when 'm'
524
+ parsed_dt = parsed_dt.prev_month(number)
525
+ when 'y'
526
+ parsed_dt = parsed_dt.prev_year(number)
527
+ else
528
+ return nil
529
+ end
530
+
531
+ parsed_dt
532
+ end
533
+
534
+ # Parse a single semver numeric identifier, rejecting empty, non-digit, or
535
+ # leading-zero values per semver 2.0.0 §2.
536
+ def self.parse_semver_numeric(part)
537
+ raise InconclusiveMatchError, 'Invalid semver format' if part.nil? || part.empty? || part !~ /^\d+$/
538
+ # Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
539
+ raise InconclusiveMatchError, 'Invalid semver format' if part.length > 1 && part[0] == '0'
540
+
541
+ part.to_i
542
+ end
543
+
544
+ # Parse a semver string into a comparable [major, minor, patch] integer array.
545
+ # Handles v-prefix, whitespace, pre-release suffixes. Defaults missing components to 0.
546
+ def self.parse_semver(value)
547
+ raise InconclusiveMatchError, 'Invalid semver format' if value.nil?
548
+
549
+ text = value.to_s.strip.sub(/^[vV]/, '')
550
+
551
+ raise InconclusiveMatchError, 'Invalid semver format' if text.empty?
552
+
553
+ # Strip pre-release and build metadata suffixes
554
+ text = text.split('-')[0].split('+')[0]
555
+ parts = text.split('.')
556
+
557
+ raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty?
558
+
559
+ major = parse_semver_numeric(parts[0])
560
+ minor = parts.length > 1 ? parse_semver_numeric(parts[1]) : 0
561
+ patch = parts.length > 2 ? parse_semver_numeric(parts[2]) : 0
562
+
563
+ [major, minor, patch]
564
+ end
565
+
566
+ # Returns bounds for tilde (~) range:
567
+ # ~X → >=X.0.0 <(X+1).0.0
568
+ # ~X.Y → >=X.Y.0 <X.(Y+1).0
569
+ # ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
570
+ def self.semver_tilde_bounds(value)
571
+ major, minor, patch = parse_semver(value)
572
+ lower = [major, minor, patch]
573
+
574
+ # Determine how many components were provided
575
+ text = value.to_s.strip.sub(/^[vV]/, '')
576
+ text = text.split('-')[0].split('+')[0]
577
+ component_count = text.split('.').length
578
+
579
+ upper = if component_count == 1
580
+ # Major-only: bump major
581
+ [major + 1, 0, 0]
582
+ else
583
+ # Major.minor or major.minor.patch: bump minor
584
+ [major, minor + 1, 0]
585
+ end
586
+
587
+ [lower, upper]
588
+ end
589
+
590
+ # Returns bounds for caret (^) range per semver spec:
591
+ # ^X.Y.Z where X > 0 → >=X.Y.Z <(X+1).0.0
592
+ # ^0.Y.Z where Y > 0 → >=0.Y.Z <0.(Y+1).0
593
+ # ^0.0.Z → >=0.0.Z <0.0.(Z+1)
594
+ def self.semver_caret_bounds(value)
595
+ major, minor, patch = parse_semver(value)
596
+ lower = [major, minor, patch]
597
+
598
+ upper = if major.positive?
599
+ [major + 1, 0, 0]
600
+ elsif minor.positive?
601
+ [0, minor + 1, 0]
602
+ else
603
+ [0, 0, patch + 1]
604
+ end
605
+
606
+ [lower, upper]
607
+ end
608
+
609
+ # Returns bounds for wildcard (*) range:
610
+ # X.* or X → >=X.0.0 <(X+1).0.0
611
+ # X.Y.* → >=X.Y.0 <X.(Y+1).0
612
+ def self.semver_wildcard_bounds(value)
613
+ cleaned = value.to_s.strip.sub(/^[vV]/, '').gsub('*', '').chomp('.')
614
+ parts = cleaned.split('.').reject(&:empty?)
615
+
616
+ raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty?
617
+
618
+ numeric = parts.map do |part|
619
+ parse_semver_numeric(part)
620
+ rescue InconclusiveMatchError
621
+ raise InconclusiveMatchError, 'Invalid semver wildcard format'
622
+ end
623
+
624
+ major = numeric[0]
625
+ case numeric.length
626
+ when 1
627
+ [[major, 0, 0], [major + 1, 0, 0]]
628
+ when 2
629
+ minor = numeric[1]
630
+ [[major, minor, 0], [major, minor + 1, 0]]
631
+ else
632
+ minor = numeric[1]
633
+ patch = numeric[2]
634
+ [[major, minor, patch], [major, minor, patch + 1]]
635
+ end
636
+ end
637
+
638
+ # Service legacy classifies the entire filter, not individual array members.
639
+ def self.boolean_like?(value)
640
+ case value
641
+ when true, false then true
642
+ when String then %w[true false].include?(value.downcase)
643
+ when Array then value.all? { |member| boolean_like?(member) }
644
+ else false
645
+ end
646
+ end
647
+
648
+ def self.legacy_truthy?(value)
649
+ case value
650
+ when true then true
651
+ when String then value.downcase == 'true'
652
+ when Array then value.all? { |member| legacy_truthy?(member) }
653
+ else false
654
+ end
655
+ end
656
+
657
+ def self.sorted_composite(value)
658
+ case value
659
+ when Hash
660
+ value.transform_keys(&:to_s).sort.to_h.transform_values { |member| sorted_composite(member) }
661
+ when Array
662
+ value.map { |member| sorted_composite(member) }
663
+ else
664
+ value
665
+ end
666
+ end
667
+
668
+ def self.property_string(value)
669
+ # Keep numeric normalization unchanged; null and composites need JSON rather
670
+ # than Ruby's nil.to_s / Array#to_s representations.
671
+ case value
672
+ when nil, Array, Hash then JSON.generate(sorted_composite(value))
673
+ else value.to_s
674
+ end.downcase
675
+ end
676
+
677
+ def self.exact_property_match?(filter, value, property_matching_version)
678
+ return legacy_truthy?(filter) == legacy_truthy?(value) if property_matching_version != 2 && boolean_like?(filter)
679
+ return legacy_truthy?(value) if filter.is_a?(Array) && filter.empty?
680
+
681
+ if filter.is_a?(Array)
682
+ filter.any? { |member| property_string(member) == property_string(value) }
683
+ else
684
+ property_string(filter) == property_string(value)
685
+ end
686
+ end
687
+
688
+ private_class_method :boolean_like?, :legacy_truthy?, :sorted_composite, :property_string, :exact_property_match?
689
+
690
+ def self.match_property(property, property_values, cohort_properties = {}, property_matching_version: 1)
691
+ # only looks for matches where key exists in property_values
692
+
693
+ Txlemetry::Utils.symbolize_keys! property
694
+ Txlemetry::Utils.symbolize_keys! property_values
695
+
696
+ # Handle cohort properties
697
+ if extract_value(property, :type) == 'cohort'
698
+ return match_cohort(property, property_values, cohort_properties,
699
+ property_matching_version: property_matching_version)
700
+ end
701
+
702
+ key = property[:key].to_sym
703
+ value = property[:value]
704
+ operator = property[:operator] || 'exact'
705
+
706
+ if !property_values.key?(key)
707
+ raise InconclusiveMatchError, "Property #{key} not found in property_values"
708
+ elsif operator == 'is_not_set'
709
+ return false
710
+ end
711
+
712
+ override_value = property_values[key]
713
+
714
+ case operator
715
+ when 'exact', 'is_not'
716
+ matches = exact_property_match?(value, override_value, property_matching_version)
717
+ operator == 'exact' ? matches : !matches
718
+ when 'is_set'
719
+ property_values.key?(key)
720
+ when 'icontains'
721
+ override_value.to_s.downcase(:ascii).include?(value.to_s.downcase(:ascii))
722
+ when 'not_icontains'
723
+ !override_value.to_s.downcase(:ascii).include?(value.to_s.downcase(:ascii))
724
+ when 'starts_with'
725
+ override_value.to_s.downcase(:ascii).start_with?(value.to_s.downcase(:ascii))
726
+ when 'not_starts_with'
727
+ !override_value.to_s.downcase(:ascii).start_with?(value.to_s.downcase(:ascii))
728
+ when 'ends_with'
729
+ override_value.to_s.downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
730
+ when 'not_ends_with'
731
+ !override_value.to_s.downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
732
+ when 'regex'
733
+ Txlemetry::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
734
+ when 'not_regex'
735
+ Txlemetry::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
736
+ when 'gt', 'gte', 'lt', 'lte'
737
+ parsed_value = nil
738
+ begin
739
+ parsed_value = Float(value)
740
+ rescue StandardError # rubocop:disable Lint/SuppressedException
741
+ end
742
+ if !parsed_value.nil? && !override_value.nil?
743
+ if override_value.is_a?(String)
744
+ compare(override_value, value.to_s, operator)
745
+ else
746
+ compare(override_value, parsed_value, operator)
747
+ end
748
+ else
749
+ compare(override_value.to_s, value.to_s, operator)
750
+ end
751
+ when 'is_date_before', 'is_date_after'
752
+ override_date = Txlemetry::Utils.convert_to_datetime(override_value.to_s)
753
+ parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
754
+
755
+ parsed_date = Txlemetry::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
756
+
757
+ raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
758
+
759
+ if operator == 'is_date_before'
760
+ override_date < parsed_date
761
+ elsif operator == 'is_date_after'
762
+ override_date > parsed_date
763
+ end
764
+ when 'semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte'
765
+ override_parsed = parse_semver(override_value)
766
+ flag_parsed = parse_semver(value)
767
+
768
+ case operator
769
+ when 'semver_eq'
770
+ override_parsed == flag_parsed
771
+ when 'semver_neq'
772
+ override_parsed != flag_parsed
773
+ when 'semver_gt'
774
+ (override_parsed <=> flag_parsed) == 1
775
+ when 'semver_gte'
776
+ (override_parsed <=> flag_parsed) >= 0
777
+ when 'semver_lt'
778
+ (override_parsed <=> flag_parsed) == -1
779
+ when 'semver_lte'
780
+ (override_parsed <=> flag_parsed) <= 0
781
+ end
782
+ when 'semver_tilde'
783
+ override_parsed = parse_semver(override_value)
784
+ lower, upper = semver_tilde_bounds(value)
785
+ (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
786
+ when 'semver_caret'
787
+ override_parsed = parse_semver(override_value)
788
+ lower, upper = semver_caret_bounds(value)
789
+ (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
790
+ when 'semver_wildcard'
791
+ override_parsed = parse_semver(override_value)
792
+ lower, upper = semver_wildcard_bounds(value)
793
+ (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
794
+ else
795
+ raise InconclusiveMatchError, "Unknown operator: #{operator}"
796
+ end
797
+ end
798
+
799
+ def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
800
+ # Cohort properties are in the form of property groups like this:
801
+ # {
802
+ # "cohort_id" => {
803
+ # "type" => "AND|OR",
804
+ # "values" => [{
805
+ # "key" => "property_name", "value" => "property_value"
806
+ # }]
807
+ # }
808
+ # }
809
+ cohort_id = extract_value(property, :value).to_s
810
+ property_group = find_cohort_property(cohort_properties, cohort_id)
811
+
812
+ unless property_group
813
+ raise RequiresServerEvaluation,
814
+ "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
815
+ end
816
+
817
+ match_property_group(property_group, property_values, cohort_properties,
818
+ property_matching_version: property_matching_version)
819
+ end
820
+
821
+ def self.match_property_group(property_group, property_values, cohort_properties, property_matching_version: 1)
822
+ return true if property_group.nil? || property_group.empty?
823
+
824
+ group_type = extract_value(property_group, :type)
825
+ properties = extract_value(property_group, :values)
826
+
827
+ return true if properties.nil? || properties.empty?
828
+
829
+ if nested_property_group?(properties)
830
+ match_nested_property_group(properties, group_type, property_values, cohort_properties,
831
+ property_matching_version: property_matching_version)
832
+ else
833
+ match_regular_property_group(properties, group_type, property_values, cohort_properties,
834
+ property_matching_version: property_matching_version)
835
+ end
836
+ end
837
+
838
+ def self.extract_value(hash, key)
839
+ return nil unless hash.is_a?(Hash)
840
+
841
+ hash[key] || hash[key.to_s]
842
+ end
843
+
844
+ def self.find_cohort_property(cohort_properties, cohort_id)
845
+ return nil unless cohort_properties.is_a?(Hash)
846
+
847
+ cohort_properties[cohort_id] || cohort_properties[cohort_id.to_sym]
848
+ end
849
+
850
+ def self.nested_property_group?(properties)
851
+ return false unless properties&.any?
852
+
853
+ first_property = properties[0]
854
+ return false unless first_property.is_a?(Hash)
855
+
856
+ first_property.key?(:values) || first_property.key?('values')
857
+ end
858
+
859
+ def self.match_nested_property_group(properties, group_type, property_values, cohort_properties,
860
+ property_matching_version: 1)
861
+ case group_type
862
+ when 'AND'
863
+ properties.each do |property|
864
+ return false unless match_property_group(property, property_values, cohort_properties,
865
+ property_matching_version: property_matching_version)
866
+ end
867
+ true
868
+ when 'OR'
869
+ properties.each do |property|
870
+ return true if match_property_group(property, property_values, cohort_properties,
871
+ property_matching_version: property_matching_version)
872
+ end
873
+ false
874
+ else
875
+ raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
876
+ end
877
+ end
878
+
879
+ def self.match_regular_property_group(properties, group_type, property_values, cohort_properties,
880
+ property_matching_version: 1)
881
+ # Validate group type upfront
882
+ raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
883
+
884
+ error_matching_locally = false
885
+
886
+ properties.each do |prop|
887
+ Txlemetry::Utils.symbolize_keys!(prop)
888
+
889
+ matches = match_property(prop, property_values, cohort_properties,
890
+ property_matching_version: property_matching_version)
891
+
892
+ negated = prop[:negation] || false
893
+ final_result = negated ? !matches : matches
894
+
895
+ # Short-circuit based on group type
896
+ if group_type == 'AND'
897
+ return false unless final_result
898
+ elsif final_result # group_type == 'OR'
899
+ return true
900
+ end
901
+ rescue RequiresServerEvaluation
902
+ # Immediately propagate - this condition requires server-side data
903
+ raise
904
+ rescue InconclusiveMatchError => e
905
+ Txlemetry::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
906
+ error_matching_locally = true
907
+ end
908
+
909
+ raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
910
+
911
+ # If we reach here, return default based on group type
912
+ group_type == 'AND'
913
+ end
914
+
915
+ # Evaluates a flag dependency property according to the dependency chain algorithm.
916
+ #
917
+ # @param property [Hash] Flag property with type="flag" and dependency_chain
918
+ # @param evaluation_cache [Hash] Cache for storing evaluation results
919
+ # @param distinct_id [String] The distinct ID being evaluated
920
+ # @param properties [Hash] Person properties for evaluation
921
+ # @param cohort_properties [Hash] Cohort properties for evaluation
922
+ # @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
923
+ def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties,
924
+ snapshot: _evaluation_snapshot)
925
+ if property[:operator] != 'flag_evaluates_to'
926
+ # Should never happen, but just in case
927
+ raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
928
+ end
929
+
930
+ if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
931
+ # Cannot evaluate flag dependencies without required context
932
+ raise InconclusiveMatchError,
933
+ "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
934
+ 'without feature flags loaded or evaluation_cache'
935
+ end
936
+
937
+ # Check if dependency_chain is present - it should always be provided for flag dependencies
938
+ unless property.key?(:dependency_chain)
939
+ # Missing dependency_chain indicates malformed server data
940
+ raise InconclusiveMatchError,
941
+ "Flag dependency property for '#{property[:key] || 'unknown'}' " \
942
+ "is missing required 'dependency_chain' field"
943
+ end
944
+
945
+ dependency_chain = property[:dependency_chain]
946
+
947
+ # Handle circular dependency (empty chain means circular)
948
+ if dependency_chain.empty?
949
+ Txlemetry::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
950
+ raise InconclusiveMatchError,
951
+ "Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
952
+ end
953
+
954
+ # Evaluate all dependencies in the chain order
955
+ dependency_chain.each do |dep_flag_key|
956
+ unless evaluation_cache.key?(dep_flag_key)
957
+ # Need to evaluate this dependency first
958
+ dep_flag = snapshot[:flags_by_key][dep_flag_key]
959
+ if dep_flag.nil?
960
+ # Missing flag dependency - cannot evaluate locally
961
+ evaluation_cache[dep_flag_key] = nil
962
+ raise InconclusiveMatchError,
963
+ "Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
964
+ elsif !dep_flag[:active]
965
+ # Check if the flag is active (same check as in _compute_flag_locally)
966
+ evaluation_cache[dep_flag_key] = false
967
+ else
968
+ # Recursively evaluate the dependency using existing instance method
969
+ begin
970
+ dep_result = match_feature_flag_properties(
971
+ dep_flag,
972
+ distinct_id,
973
+ properties,
974
+ evaluation_cache,
975
+ cohort_properties,
976
+ snapshot: snapshot
977
+ )
978
+ evaluation_cache[dep_flag_key] = dep_result
979
+ rescue InconclusiveMatchError => e
980
+ # If we can't evaluate a dependency, store nil and propagate the error
981
+ evaluation_cache[dep_flag_key] = nil
982
+ raise InconclusiveMatchError,
983
+ "Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
984
+ end
985
+ end
986
+ end
987
+
988
+ # Check the cached result
989
+ cached_result = evaluation_cache[dep_flag_key]
990
+ if cached_result.nil?
991
+ # Previously inconclusive - raise error again
992
+ raise InconclusiveMatchError,
993
+ "Flag dependency '#{dep_flag_key}' was previously inconclusive"
994
+ elsif !cached_result
995
+ # Definitive False result - dependency failed
996
+ return false
997
+ end
998
+ end
999
+
1000
+ # Get the expected value of the immediate dependency and the actual value
1001
+ expected_value = property[:value]
1002
+ # The flag we want to evaluate is defined by :key which should ALSO be the last key in the dependency chain
1003
+ actual_value = evaluation_cache[property[:key]]
1004
+
1005
+ self.class.matches_dependency_value(expected_value, actual_value)
1006
+ end
1007
+
1008
+ def self.matches_dependency_value(expected_value, actual_value)
1009
+ # Check if the actual flag value matches the expected dependency value.
1010
+ #
1011
+ # - String variant case: check for exact match or boolean true
1012
+ # - Boolean case: must match expected boolean value
1013
+ #
1014
+ # @param expected_value [Object] The expected value from the property
1015
+ # @param actual_value [Object] The actual value returned by the flag evaluation
1016
+ # @return [Boolean] True if the values match according to flag dependency rules
1017
+
1018
+ # String variant case - check for exact match or boolean true
1019
+ if actual_value.is_a?(String) && !actual_value.empty?
1020
+ if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
1021
+ # Any variant matches boolean true
1022
+ return expected_value
1023
+ elsif expected_value.is_a?(String)
1024
+ # variants are case-sensitive, hence our comparison is too
1025
+ return actual_value == expected_value
1026
+ else
1027
+ return false
1028
+ end
1029
+
1030
+ # Boolean case - must match expected boolean value
1031
+ elsif actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass)
1032
+ return actual_value == expected_value if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
1033
+ end
1034
+
1035
+ # Default case
1036
+ false
1037
+ end
1038
+
1039
+ private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
1040
+ :match_nested_property_group, :match_regular_property_group
1041
+
1042
+ # Publish/capture all matching state together so a poll cannot switch semantics
1043
+ # (or dependency/cohort definitions) halfway through one local evaluation.
1044
+ def _evaluation_snapshot
1045
+ @definition_snapshot.value
1046
+ end
1047
+
1048
+ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {},
1049
+ snapshot: _evaluation_snapshot)
1050
+ raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
1051
+
1052
+ return false unless flag[:active]
1053
+
1054
+ # Create evaluation cache for flag dependencies
1055
+ evaluation_cache = {}
1056
+
1057
+ flag_filters = flag[:filters] || {}
1058
+
1059
+ aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
1060
+ if aggregation_group_type_index.nil?
1061
+ local_person_properties = person_properties || {}
1062
+ unless local_person_properties.key?(:distinct_id) || local_person_properties.key?('distinct_id')
1063
+ local_person_properties = local_person_properties.merge(distinct_id: distinct_id)
1064
+ end
1065
+
1066
+ return match_feature_flag_properties(flag, distinct_id, local_person_properties, evaluation_cache,
1067
+ snapshot[:cohorts], groups: groups, group_properties: group_properties,
1068
+ snapshot: snapshot)
1069
+ end
1070
+
1071
+ group_name = snapshot[:group_type_mapping][aggregation_group_type_index.to_s.to_sym]
1072
+
1073
+ if group_name.nil?
1074
+ logger.warn(
1075
+ "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
1076
+ )
1077
+ # failover to `/flags/`
1078
+ raise InconclusiveMatchError, 'Flag has unknown group type index'
1079
+ end
1080
+
1081
+ group_name_symbol = group_name.to_sym
1082
+
1083
+ unless groups.key?(group_name_symbol)
1084
+ # Group flags are never enabled if appropriate `groups` aren't passed in
1085
+ # don't failover to `/flags/`, since response will be the same
1086
+ logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
1087
+ return false
1088
+ end
1089
+
1090
+ focused_group_properties = group_properties[group_name_symbol]
1091
+ match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
1092
+ snapshot[:cohorts], groups: groups, group_properties: group_properties,
1093
+ snapshot: snapshot)
1094
+ end
1095
+
1096
+ def _compute_flag_payload_locally(key, match_value, snapshot: _evaluation_snapshot)
1097
+ return nil if snapshot[:flags_by_key].nil?
1098
+
1099
+ key = key.to_s
1100
+ response = nil
1101
+ if [true, false].include? match_value
1102
+ response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_s.to_sym)
1103
+ elsif match_value.is_a? String
1104
+ response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_sym)
1105
+ end
1106
+ response
1107
+ end
1108
+
1109
+ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
1110
+ groups: {}, group_properties: {}, snapshot: _evaluation_snapshot)
1111
+ flag_filters = flag[:filters] || {}
1112
+
1113
+ flag_conditions = flag_filters[:groups] || []
1114
+ flag_aggregation = flag_filters[:aggregation_group_type_index]
1115
+ early_exit = flag_filters[:early_exit] == true
1116
+ is_inconclusive = false
1117
+ result = nil
1118
+
1119
+ # NOTE: This NEEDS to be `each` because `each_key` breaks
1120
+ flag_conditions.each do |condition|
1121
+ # Per-condition aggregation overrides only when the condition explicitly
1122
+ # sets its own aggregation_group_type_index (mixed targeting). When absent,
1123
+ # use the properties/bucketing already resolved by the caller.
1124
+ condition_aggregation = condition.fetch(:aggregation_group_type_index, flag_aggregation)
1125
+
1126
+ effective_properties = properties
1127
+ effective_bucketing = distinct_id
1128
+
1129
+ # Mixed-override path: condition-level aggregation differs from flag-level.
1130
+ # This assumes flag-level aggregation is nil for mixed flags.
1131
+ if condition_aggregation != flag_aggregation
1132
+ if condition_aggregation.nil?
1133
+ # Person condition under a mixed flag — caller already passed person props/bucketing.
1134
+ else
1135
+ group_name = snapshot[:group_type_mapping][condition_aggregation.to_s.to_sym]
1136
+ if group_name.nil? || !groups.key?(group_name.to_sym)
1137
+ logger.debug do
1138
+ "[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
1139
+ "group type index #{condition_aggregation} not available"
1140
+ end
1141
+ next
1142
+ end
1143
+ unless group_properties.key?(group_name.to_sym)
1144
+ is_inconclusive = true
1145
+ next
1146
+ end
1147
+ effective_properties = group_properties[group_name.to_sym]
1148
+ effective_bucketing = groups[group_name.to_sym]
1149
+ end
1150
+ end
1151
+
1152
+ case condition_match_outcome(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
1153
+ cohort_properties, snapshot: snapshot)
1154
+ when :match
1155
+ variant_override = condition[:variant]
1156
+ flag_multivariate = flag_filters[:multivariate] || {}
1157
+ flag_variants = flag_multivariate[:variants] || []
1158
+ variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
1159
+ variant_override
1160
+ else
1161
+ get_matching_variant(flag, effective_bucketing)
1162
+ end
1163
+ result = variant || true
1164
+ break
1165
+ when :out_of_rollout_bound
1166
+ break if early_exit
1167
+ end
1168
+ rescue RequiresServerEvaluation
1169
+ # Static cohort or other missing server-side data - must fallback to API
1170
+ raise
1171
+ rescue InconclusiveMatchError
1172
+ # Evaluation error (bad regex, invalid date, missing property, etc.)
1173
+ # Track that we had an inconclusive match, but try other conditions
1174
+ is_inconclusive = true
1175
+ end
1176
+
1177
+ if !result.nil?
1178
+ return result
1179
+ elsif is_inconclusive
1180
+ raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
1181
+ end
1182
+
1183
+ # We can only return False when all conditions are False
1184
+ false
1185
+ end
1186
+
1187
+ def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
1188
+ snapshot: _evaluation_snapshot)
1189
+ condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
1190
+ cohort_properties, snapshot: snapshot) == :match
1191
+ end
1192
+
1193
+ # Evaluates a single condition group and returns a tri-state outcome:
1194
+ # :match - property filters matched AND the rollout included the user
1195
+ # :no_match - a property filter did NOT match
1196
+ # :out_of_rollout_bound - property filters matched (or there were none) but the
1197
+ # rollout percentage excluded the user
1198
+ # Distinguishing :no_match from :out_of_rollout_bound lets the caller implement the
1199
+ # early_exit behavior (mirrors the server-side Rust evaluation engine).
1200
+ def condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
1201
+ snapshot: _evaluation_snapshot)
1202
+ rollout_percentage = condition[:rollout_percentage]
1203
+
1204
+ unless (condition[:properties] || []).empty?
1205
+ unless condition[:properties].all? do |prop|
1206
+ if prop[:type] == 'flag'
1207
+ evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
1208
+ snapshot: snapshot)
1209
+ else
1210
+ FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
1211
+ property_matching_version: snapshot[:property_matching_version])
1212
+ end
1213
+ end
1214
+ return :no_match
1215
+ end
1216
+
1217
+ return :match if rollout_percentage.nil?
1218
+ end
1219
+
1220
+ if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
1221
+ return :out_of_rollout_bound
1222
+ end
1223
+
1224
+ :match
1225
+ end
1226
+
1227
+ # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
1228
+ # Given the same distinct_id and key, it'll always return the same float. These floats are
1229
+ # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
1230
+ # we can do _hash(key, distinct_id) < 0.2
1231
+ def _hash(key, distinct_id, salt = '')
1232
+ hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
1233
+ (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
1234
+ end
1235
+
1236
+ def get_matching_variant(flag, distinct_id)
1237
+ hash_value = _hash(flag[:key], distinct_id, 'variant')
1238
+ matching_variant = variant_lookup_table(flag).find do |variant|
1239
+ hash_value >= variant[:value_min] and hash_value < variant[:value_max]
1240
+ end
1241
+ matching_variant.nil? ? nil : matching_variant[:key]
1242
+ end
1243
+
1244
+ def variant_lookup_table(flag)
1245
+ lookup_table = []
1246
+ value_min = 0
1247
+ flag_filters = flag[:filters] || {}
1248
+ variants = flag_filters[:multivariate] || {}
1249
+ multivariates = variants[:variants] || []
1250
+ multivariates.each do |variant|
1251
+ value_max = value_min + (variant[:rollout_percentage].to_f / 100)
1252
+ lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
1253
+ value_min = value_max
1254
+ end
1255
+ lookup_table
1256
+ end
1257
+
1258
+ def _load_feature_flags
1259
+ should_fetch = true
1260
+
1261
+ if @flag_definition_cache_provider
1262
+ begin
1263
+ should_fetch = @flag_definition_cache_provider.should_fetch_flag_definitions?
1264
+ rescue StandardError => e
1265
+ logger.error("[FEATURE FLAGS] Cache provider should_fetch error: #{e}")
1266
+ should_fetch = true
1267
+ end
1268
+ end
1269
+
1270
+ unless should_fetch
1271
+ begin
1272
+ cached_data = @flag_definition_cache_provider.flag_definitions
1273
+ if cached_data
1274
+ logger.debug '[FEATURE FLAGS] Using cached flag definitions from external cache'
1275
+ _apply_flag_definitions(cached_data)
1276
+ return
1277
+ elsif @feature_flags.empty?
1278
+ # Emergency fallback: cache empty and no flags loaded -> fetch from API
1279
+ should_fetch = true
1280
+ end
1281
+ rescue StandardError => e
1282
+ logger.error("[FEATURE FLAGS] Cache provider get error: #{e}")
1283
+ should_fetch = true
1284
+ end
1285
+ end
1286
+
1287
+ _fetch_and_apply_flag_definitions if should_fetch
1288
+ end
1289
+
1290
+ def _fetch_and_apply_flag_definitions
1291
+ begin
1292
+ res = _request_feature_flag_definitions(etag: @flags_etag.value)
1293
+ rescue StandardError => e
1294
+ @on_error.call(-1, e.to_s)
1295
+ return
1296
+ end
1297
+
1298
+ # Handle 304 Not Modified - flags haven't changed, skip processing
1299
+ # Only update ETag if the 304 response includes one
1300
+ if res[:not_modified]
1301
+ @flags_etag.value = res[:etag] if res[:etag]
1302
+ logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
1303
+ return
1304
+ end
1305
+
1306
+ # Handle quota limits with 402 status
1307
+ if res.is_a?(Hash) && res[:status] == 402
1308
+ logger.warn(
1309
+ '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
1310
+ 'Learn more about billing limits at https://txlemetry.com/docs/billing/limits-alerts'
1311
+ )
1312
+ @flag_definitions_update_mutex.synchronize do
1313
+ definitions_were_loaded = definitions_loaded?
1314
+ @feature_flags = Concurrent::Array.new
1315
+ @feature_flags_by_key = {}
1316
+ @group_type_mapping = Concurrent::Hash.new
1317
+ @cohorts = Concurrent::Hash.new
1318
+ @flag_definitions_loaded_at.value = nil
1319
+ @minimal_flag_called_events = false
1320
+ @definition_snapshot.value = {
1321
+ flags: @feature_flags, flags_by_key: @feature_flags_by_key,
1322
+ group_type_mapping: @group_type_mapping, cohorts: @cohorts,
1323
+ minimal_flag_called_events: false, property_matching_version: 1
1324
+ }.freeze
1325
+ @loaded_flags_successfully_once.make_false
1326
+ @quota_limited.make_true
1327
+ @on_flag_definitions_updated&.call if definitions_were_loaded
1328
+ end
1329
+ return
1330
+ end
1331
+
1332
+ if res.key?(:flags)
1333
+ # Only update ETag on successful responses with flag data
1334
+ @flags_etag.value = res[:etag]
1335
+
1336
+ _apply_flag_definitions(res)
1337
+ _store_in_cache_provider
1338
+ else
1339
+ logger.debug "Failed to load feature flags: #{res}"
1340
+ end
1341
+ end
1342
+
1343
+ def _store_in_cache_provider
1344
+ return unless @flag_definition_cache_provider
1345
+
1346
+ begin
1347
+ snapshot = _evaluation_snapshot
1348
+ data = {
1349
+ flags: snapshot[:flags].to_a,
1350
+ group_type_mapping: snapshot[:group_type_mapping].to_h,
1351
+ cohorts: snapshot[:cohorts].to_h,
1352
+ minimal_flag_called_events: snapshot[:minimal_flag_called_events],
1353
+ property_matching_version: snapshot[:property_matching_version]
1354
+ }
1355
+ @flag_definition_cache_provider.on_flag_definitions_received(data)
1356
+ rescue StandardError => e
1357
+ logger.error("[FEATURE FLAGS] Cache provider store error: #{e}")
1358
+ end
1359
+ end
1360
+
1361
+ def _apply_flag_definitions(data)
1362
+ flags = get_by_symbol_or_string_key(data, 'flags') || []
1363
+ group_type_mapping = get_by_symbol_or_string_key(data, 'group_type_mapping') || {}
1364
+ cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
1365
+ minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')
1366
+
1367
+ property_matching_version = get_by_symbol_or_string_key(data, 'property_matching_version') || 1
1368
+ new_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
1369
+ new_by_key = {}
1370
+ new_flags.each do |flag|
1371
+ new_by_key[flag[:key]] = flag unless flag[:key].nil?
1372
+ end
1373
+ new_group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
1374
+ new_cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1375
+
1376
+ # A read of the new definitions must not record its event before the tracker reset.
1377
+ @flag_definitions_update_mutex.synchronize do
1378
+ @definition_snapshot.value = {
1379
+ flags: new_flags, flags_by_key: new_by_key,
1380
+ group_type_mapping: new_group_type_mapping, cohorts: new_cohorts,
1381
+ minimal_flag_called_events: minimal_flag_called_events == true,
1382
+ property_matching_version: property_matching_version
1383
+ }.freeze
1384
+ @feature_flags = new_flags
1385
+ @feature_flags_by_key = new_by_key
1386
+ @group_type_mapping = new_group_type_mapping
1387
+ @cohorts = new_cohorts
1388
+ @minimal_flag_called_events = minimal_flag_called_events == true
1389
+ @flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
1390
+ @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1391
+ @on_flag_definitions_updated&.call
1392
+ end
1393
+ logger.debug "Loaded #{new_flags.length} feature flags and #{new_cohorts.length} cohorts"
1394
+ end
1395
+
1396
+ def _request_feature_flag_definitions(etag: nil)
1397
+ uri = URI("#{@host}/flags/definitions")
1398
+ uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
1399
+ req = Net::HTTP::Get.new(uri)
1400
+ req['Authorization'] = "Bearer #{@secret_key}"
1401
+ req['If-None-Match'] = etag if etag
1402
+
1403
+ _request(uri, req, nil, include_etag: true)
1404
+ end
1405
+
1406
+ def _request_feature_flag_evaluation(data = {})
1407
+ uri = URI("#{@host}/flags/?v=2")
1408
+ req = Net::HTTP::Post.new(uri)
1409
+ req['Content-Type'] = 'application/json'
1410
+ data['token'] = @project_api_key
1411
+ req.body = data.to_json
1412
+
1413
+ _request(
1414
+ uri,
1415
+ req,
1416
+ @feature_flag_request_timeout_seconds,
1417
+ retry_status_codes: RETRYABLE_FLAGS_REQUEST_STATUS_CODES
1418
+ )
1419
+ end
1420
+
1421
+ def _request_remote_config_payload(flag_key)
1422
+ uri = URI("#{@host}/api/projects/@current/feature_flags/#{flag_key}/remote_config")
1423
+ uri.query = URI.encode_www_form([['token', @project_api_key]])
1424
+ req = Net::HTTP::Get.new(uri)
1425
+ req['Content-Type'] = 'application/json'
1426
+ req['Authorization'] = "Bearer #{@secret_key}"
1427
+
1428
+ _request(uri, req, @feature_flag_request_timeout_seconds)
1429
+ end
1430
+
1431
+ # Transient network errors that are safe to retry. Flag requests are
1432
+ # retry-safe (stateless reads and evaluations, no server-side mutation), so
1433
+ # a one-off blip (TCP retransmit, TLS jitter, an edge/proxy hiccup) should
1434
+ # be absorbed by a retry rather than surfaced to the caller.
1435
+ RETRYABLE_REQUEST_ERRORS = [
1436
+ Timeout::Error, # covers Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
1437
+ Errno::ECONNRESET,
1438
+ EOFError
1439
+ ].freeze
1440
+ RETRYABLE_FLAGS_REQUEST_STATUS_CODES = [502, 504].freeze
1441
+
1442
+ def _request(uri, request_object, timeout = nil, include_etag: false, retry_status_codes: [])
1443
+ request_object['User-Agent'] = @user_agent
1444
+ request_timeout = timeout || 10
1445
+ backoff_policy = nil
1446
+ attempts = 0
1447
+
1448
+ loop do
1449
+ attempts += 1
1450
+ Net::HTTP.start(
1451
+ uri.hostname,
1452
+ uri.port,
1453
+ use_ssl: uri.scheme == 'https',
1454
+ open_timeout: request_timeout,
1455
+ read_timeout: request_timeout,
1456
+ write_timeout: request_timeout
1457
+ ) do |http|
1458
+ res = http.request(request_object)
1459
+ status_code = res.code.to_i
1460
+ etag = include_etag ? res['ETag'] : nil
1461
+
1462
+ if retry_status_codes.include?(status_code) && attempts <= @feature_flag_request_max_retries
1463
+ backoff_policy ||= BackoffPolicy.new
1464
+ interval = backoff_policy.next_interval.to_f / 1000
1465
+ logger.debug(
1466
+ "Retrying request to #{_mask_tokens_in_url(uri.to_s)} after HTTP #{status_code} (attempt #{attempts})"
1467
+ )
1468
+ sleep(interval)
1469
+ next
1470
+ end
1471
+
1472
+ # Handle 304 Not Modified - return special response indicating no change
1473
+ if status_code == 304
1474
+ logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
1475
+ return { not_modified: true, etag: etag, status: status_code }
1476
+ end
1477
+
1478
+ # Parse response body to hash
1479
+ begin
1480
+ response = JSON.parse(res.body, symbolize_names: true)
1481
+ # Only add status (and etag if requested) if response is a hash
1482
+ extra_fields = { status: status_code }
1483
+ extra_fields[:etag] = etag if include_etag
1484
+ response = response.merge(extra_fields) if response.is_a?(Hash)
1485
+ return response
1486
+ rescue JSON::ParserError
1487
+ # Handle case when response isn't valid JSON
1488
+ error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
1489
+ error_response[:etag] = etag if include_etag
1490
+ return error_response
1491
+ end
1492
+ end
1493
+ rescue *RETRYABLE_REQUEST_ERRORS => e
1494
+ if attempts <= @feature_flag_request_max_retries
1495
+ backoff_policy ||= BackoffPolicy.new
1496
+ interval = backoff_policy.next_interval.to_f / 1000
1497
+ logger.debug("Retrying request to #{_mask_tokens_in_url(uri.to_s)} after #{e.class} (attempt #{attempts})")
1498
+ sleep(interval)
1499
+ next
1500
+ end
1501
+ logger.debug("Unable to complete request to #{_mask_tokens_in_url(uri.to_s)}")
1502
+ raise
1503
+ rescue Errno::EINVAL,
1504
+ Net::HTTPBadResponse,
1505
+ Net::HTTPHeaderSyntaxError,
1506
+ Net::ProtocolError
1507
+ logger.debug("Unable to complete request to #{_mask_tokens_in_url(uri.to_s)}")
1508
+ raise
1509
+ end
1510
+ end
1511
+
1512
+ def _mask_tokens_in_url(url)
1513
+ url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
1514
+ end
1515
+ end
1516
+ end