posthog-rails 3.5.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.
@@ -0,0 +1,1004 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'posthog/version'
7
+ require 'posthog/logging'
8
+ require 'posthog/feature_flag'
9
+ require 'digest'
10
+
11
+ module PostHog
12
+ class InconclusiveMatchError < StandardError
13
+ end
14
+
15
+ # RequiresServerEvaluation is raised when feature flag evaluation requires
16
+ # server-side data that is not available locally (e.g., static cohorts,
17
+ # experience continuity). This error should propagate immediately to trigger
18
+ # API fallback, unlike InconclusiveMatchError which allows trying other conditions.
19
+ class RequiresServerEvaluation < StandardError
20
+ end
21
+
22
+ class FeatureFlagsPoller
23
+ include PostHog::Logging
24
+ include PostHog::Utils
25
+
26
+ def initialize(
27
+ polling_interval,
28
+ personal_api_key,
29
+ project_api_key,
30
+ host,
31
+ feature_flag_request_timeout_seconds,
32
+ on_error = nil
33
+ )
34
+ @polling_interval = polling_interval || 30
35
+ @personal_api_key = personal_api_key
36
+ @project_api_key = project_api_key
37
+ @host = host
38
+ @feature_flags = Concurrent::Array.new
39
+ @group_type_mapping = Concurrent::Hash.new
40
+ @cohorts = Concurrent::Hash.new
41
+ @loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
42
+ @feature_flags_by_key = nil
43
+ @feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
44
+ @on_error = on_error || proc { |status, error| }
45
+ @quota_limited = Concurrent::AtomicBoolean.new(false)
46
+ @flags_etag = Concurrent::AtomicReference.new(nil)
47
+ @task =
48
+ Concurrent::TimerTask.new(
49
+ execution_interval: polling_interval
50
+ ) { _load_feature_flags }
51
+
52
+ # If no personal API key, disable local evaluation & thus polling for definitions
53
+ if @personal_api_key.nil?
54
+ logger.info 'No personal API key provided, disabling local evaluation'
55
+ @loaded_flags_successfully_once.make_true
56
+ else
57
+ # load once before timer
58
+ load_feature_flags
59
+ @task.execute
60
+ end
61
+ end
62
+
63
+ def load_feature_flags(force_reload = false)
64
+ return unless @loaded_flags_successfully_once.false? || force_reload
65
+
66
+ _load_feature_flags
67
+ end
68
+
69
+ def get_feature_variants(
70
+ distinct_id,
71
+ groups = {},
72
+ person_properties = {},
73
+ group_properties = {},
74
+ only_evaluate_locally = false,
75
+ raise_on_error = false
76
+ )
77
+ # TODO: Convert to options hash for easier argument passing
78
+ flags_data = get_all_flags_and_payloads(
79
+ distinct_id,
80
+ groups,
81
+ person_properties,
82
+ group_properties,
83
+ only_evaluate_locally,
84
+ raise_on_error
85
+ )
86
+
87
+ if flags_data.key?(:featureFlags)
88
+ stringify_keys(flags_data[:featureFlags] || {})
89
+ else
90
+ logger.debug "Missing feature flags key: #{flags_data.to_json}"
91
+ {}
92
+ end
93
+ end
94
+
95
+ def get_feature_payloads(
96
+ distinct_id,
97
+ groups = {},
98
+ person_properties = {},
99
+ group_properties = {},
100
+ _only_evaluate_locally = false
101
+ )
102
+ flags_data = get_all_flags_and_payloads(
103
+ distinct_id,
104
+ groups,
105
+ person_properties,
106
+ group_properties
107
+ )
108
+
109
+ if flags_data.key?(:featureFlagPayloads)
110
+ stringify_keys(flags_data[:featureFlagPayloads] || {})
111
+ else
112
+ logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
113
+ {}
114
+ end
115
+ end
116
+
117
+ def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
118
+ request_data = {
119
+ distinct_id: distinct_id,
120
+ groups: groups,
121
+ person_properties: person_properties,
122
+ group_properties: group_properties
123
+ }
124
+
125
+ flags_response = _request_feature_flag_evaluation(request_data)
126
+
127
+ # Only normalize if we have flags in the response
128
+ if flags_response[:flags]
129
+ # v4 format
130
+ flags_hash = flags_response[:flags].transform_values do |flag|
131
+ FeatureFlag.new(flag)
132
+ end
133
+ flags_response[:flags] = flags_hash
134
+ flags_response[:featureFlags] = flags_hash.transform_values(&:get_value).transform_keys(&:to_sym)
135
+ flags_response[:featureFlagPayloads] = flags_hash.transform_values(&:payload).transform_keys(&:to_sym)
136
+ elsif flags_response[:featureFlags]
137
+ # v3 format
138
+ flags_response[:featureFlags] = flags_response[:featureFlags] || {}
139
+ flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
140
+ flags_response[:flags] = flags_response[:featureFlags].to_h do |key, value|
141
+ [key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
142
+ end
143
+ end
144
+
145
+ flags_response
146
+ end
147
+
148
+ def get_remote_config_payload(flag_key)
149
+ _request_remote_config_payload(flag_key)
150
+ end
151
+
152
+ def get_feature_flag(
153
+ key,
154
+ distinct_id,
155
+ groups = {},
156
+ person_properties = {},
157
+ group_properties = {},
158
+ only_evaluate_locally = false
159
+ )
160
+ # make sure they're loaded on first run
161
+ load_feature_flags
162
+
163
+ symbolize_keys! groups
164
+ symbolize_keys! person_properties
165
+ symbolize_keys! group_properties
166
+
167
+ group_properties.each_value do |value|
168
+ symbolize_keys!(value)
169
+ end
170
+
171
+ response = nil
172
+ payload = nil
173
+ feature_flag = @feature_flags_by_key&.[](key)
174
+
175
+ unless feature_flag.nil?
176
+ begin
177
+ response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
178
+ payload = _compute_flag_payload_locally(key, response) unless response.nil?
179
+ logger.debug "Successfully computed flag locally: #{key} -> #{response}"
180
+ rescue RequiresServerEvaluation, InconclusiveMatchError => e
181
+ logger.debug "Failed to compute flag #{key} locally: #{e}"
182
+ rescue StandardError => e
183
+ @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
184
+ end
185
+ end
186
+
187
+ flag_was_locally_evaluated = !response.nil?
188
+
189
+ request_id = nil
190
+ evaluated_at = nil
191
+ feature_flag_error = nil
192
+
193
+ if !flag_was_locally_evaluated && !only_evaluate_locally
194
+ begin
195
+ errors = []
196
+ flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
197
+ only_evaluate_locally, true)
198
+ if flags_data.key?(:featureFlags)
199
+ flags = stringify_keys(flags_data[:featureFlags] || {})
200
+ payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
201
+ request_id = flags_data[:requestId]
202
+ evaluated_at = flags_data[:evaluatedAt]
203
+ else
204
+ logger.debug "Missing feature flags key: #{flags_data.to_json}"
205
+ flags = {}
206
+ payloads = {}
207
+ end
208
+
209
+ status = flags_data[:status]
210
+ errors << FeatureFlagError.api_error(status) if status && status >= 400
211
+ errors << FeatureFlagError::ERRORS_WHILE_COMPUTING if flags_data[:errorsWhileComputingFlags]
212
+ errors << FeatureFlagError::QUOTA_LIMITED if flags_data[:quotaLimited]&.include?('feature_flags')
213
+ errors << FeatureFlagError::FLAG_MISSING unless flags.key?(key.to_s)
214
+
215
+ response = flags[key]
216
+ response = false if response.nil?
217
+ payload = payloads[key]
218
+ feature_flag_error = errors.join(',') unless errors.empty?
219
+
220
+ logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
221
+ rescue Timeout::Error => e
222
+ @on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
223
+ feature_flag_error = FeatureFlagError::TIMEOUT
224
+ rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, SocketError => e
225
+ @on_error.call(-1, "Connection error while fetching flags remotely: #{e}")
226
+ feature_flag_error = FeatureFlagError::CONNECTION_ERROR
227
+ rescue StandardError => e
228
+ @on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
229
+ feature_flag_error = FeatureFlagError::UNKNOWN_ERROR
230
+ end
231
+ end
232
+
233
+ [response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload]
234
+ end
235
+
236
+ def get_all_flags(
237
+ distinct_id,
238
+ groups = {},
239
+ person_properties = {},
240
+ group_properties = {},
241
+ only_evaluate_locally = false
242
+ )
243
+ if @quota_limited.true?
244
+ logger.debug 'Not fetching flags from server - quota limited'
245
+ return {}
246
+ end
247
+
248
+ # returns a string hash of all flags
249
+ response = get_all_flags_and_payloads(
250
+ distinct_id,
251
+ groups,
252
+ person_properties,
253
+ group_properties,
254
+ only_evaluate_locally
255
+ )
256
+
257
+ response[:featureFlags]
258
+ end
259
+
260
+ def get_all_flags_and_payloads(
261
+ distinct_id,
262
+ groups = {},
263
+ person_properties = {},
264
+ group_properties = {},
265
+ only_evaluate_locally = false,
266
+ raise_on_error = false
267
+ )
268
+ load_feature_flags
269
+
270
+ flags = {}
271
+ payloads = {}
272
+ fallback_to_server = @feature_flags.empty?
273
+ request_id = nil # Only for /flags requests
274
+ evaluated_at = nil # Only for /flags requests
275
+
276
+ @feature_flags.each do |flag|
277
+ match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
278
+ flags[flag[:key]] = match_value
279
+ match_payload = _compute_flag_payload_locally(flag[:key], match_value)
280
+ payloads[flag[:key]] = match_payload if match_payload
281
+ rescue RequiresServerEvaluation, InconclusiveMatchError
282
+ fallback_to_server = true
283
+ rescue StandardError => e
284
+ @on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
285
+ fallback_to_server = true
286
+ end
287
+
288
+ errors_while_computing = false
289
+ quota_limited = nil
290
+ status_code = nil
291
+
292
+ if fallback_to_server && !only_evaluate_locally
293
+ begin
294
+ flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
295
+ errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
296
+ quota_limited = flags_and_payloads[:quotaLimited]
297
+ status_code = flags_and_payloads[:status]
298
+
299
+ unless flags_and_payloads.key?(:featureFlags)
300
+ raise StandardError, "Error flags response: #{flags_and_payloads}"
301
+ end
302
+
303
+ request_id = flags_and_payloads[:requestId]
304
+ evaluated_at = flags_and_payloads[:evaluatedAt]
305
+
306
+ # Check if feature_flags are quota limited
307
+ if quota_limited&.include?('feature_flags')
308
+ logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
309
+ flags = {}
310
+ payloads = {}
311
+ else
312
+ flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
313
+ payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
314
+ end
315
+ rescue StandardError => e
316
+ @on_error.call(-1, "Error computing flag remotely: #{e}")
317
+ raise if raise_on_error
318
+ end
319
+ end
320
+
321
+ {
322
+ featureFlags: flags,
323
+ featureFlagPayloads: payloads,
324
+ requestId: request_id,
325
+ evaluatedAt: evaluated_at,
326
+ errorsWhileComputingFlags: errors_while_computing,
327
+ quotaLimited: quota_limited,
328
+ status: status_code
329
+ }
330
+ end
331
+
332
+ def get_feature_flag_payload(
333
+ key,
334
+ distinct_id,
335
+ match_value = nil,
336
+ groups = {},
337
+ person_properties = {},
338
+ group_properties = {},
339
+ only_evaluate_locally = false
340
+ )
341
+ if match_value.nil?
342
+ match_value = get_feature_flag(
343
+ key,
344
+ distinct_id,
345
+ groups,
346
+ person_properties,
347
+ group_properties,
348
+ true
349
+ )[0]
350
+ end
351
+ response = nil
352
+ response = _compute_flag_payload_locally(key, match_value) unless match_value.nil?
353
+ if response.nil? && !only_evaluate_locally
354
+ flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
355
+ response = flags_payloads[key.downcase] || nil
356
+ end
357
+ response
358
+ end
359
+
360
+ def shutdown_poller
361
+ @task.shutdown
362
+ end
363
+
364
+ # Class methods
365
+
366
+ def self.compare(lhs, rhs, operator)
367
+ case operator
368
+ when 'gt'
369
+ lhs > rhs
370
+ when 'gte'
371
+ lhs >= rhs
372
+ when 'lt'
373
+ lhs < rhs
374
+ when 'lte'
375
+ lhs <= rhs
376
+ else
377
+ raise "Invalid operator: #{operator}"
378
+ end
379
+ end
380
+
381
+ def self.relative_date_parse_for_feature_flag_matching(value)
382
+ match = /^-?([0-9]+)([a-z])$/.match(value)
383
+ parsed_dt = DateTime.now.new_offset(0)
384
+ return unless match
385
+
386
+ number = match[1].to_i
387
+
388
+ if number >= 10_000
389
+ # Guard against overflow, disallow numbers greater than 10_000
390
+ return nil
391
+ end
392
+
393
+ interval = match[2]
394
+ case interval
395
+ when 'h'
396
+ parsed_dt -= (number / 24.0)
397
+ when 'd'
398
+ parsed_dt = parsed_dt.prev_day(number)
399
+ when 'w'
400
+ parsed_dt = parsed_dt.prev_day(number * 7)
401
+ when 'm'
402
+ parsed_dt = parsed_dt.prev_month(number)
403
+ when 'y'
404
+ parsed_dt = parsed_dt.prev_year(number)
405
+ else
406
+ return nil
407
+ end
408
+
409
+ parsed_dt
410
+ end
411
+
412
+ def self.match_property(property, property_values, cohort_properties = {})
413
+ # only looks for matches where key exists in property_values
414
+ # doesn't support operator is_not_set
415
+
416
+ PostHog::Utils.symbolize_keys! property
417
+ PostHog::Utils.symbolize_keys! property_values
418
+
419
+ # Handle cohort properties
420
+ return match_cohort(property, property_values, cohort_properties) if extract_value(property, :type) == 'cohort'
421
+
422
+ key = property[:key].to_sym
423
+ value = property[:value]
424
+ operator = property[:operator] || 'exact'
425
+
426
+ if !property_values.key?(key)
427
+ raise InconclusiveMatchError, "Property #{key} not found in property_values"
428
+ elsif operator == 'is_not_set'
429
+ raise InconclusiveMatchError, 'Operator is_not_set not supported'
430
+ end
431
+
432
+ override_value = property_values[key]
433
+
434
+ case operator
435
+ when 'exact', 'is_not'
436
+ if value.is_a?(Array)
437
+ values_stringified = value.map { |val| val.to_s.downcase }
438
+ return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'
439
+
440
+ return values_stringified.none?(override_value.to_s.downcase)
441
+
442
+ end
443
+ if operator == 'exact'
444
+ value.to_s.downcase == override_value.to_s.downcase
445
+ else
446
+ value.to_s.downcase != override_value.to_s.downcase
447
+ end
448
+ when 'is_set'
449
+ property_values.key?(key)
450
+ when 'icontains'
451
+ override_value.to_s.downcase.include?(value.to_s.downcase)
452
+ when 'not_icontains'
453
+ !override_value.to_s.downcase.include?(value.to_s.downcase)
454
+ when 'regex'
455
+ PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
456
+ when 'not_regex'
457
+ PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
458
+ when 'gt', 'gte', 'lt', 'lte'
459
+ parsed_value = nil
460
+ begin
461
+ parsed_value = Float(value)
462
+ rescue StandardError # rubocop:disable Lint/SuppressedException
463
+ end
464
+ if !parsed_value.nil? && !override_value.nil?
465
+ if override_value.is_a?(String)
466
+ compare(override_value, value.to_s, operator)
467
+ else
468
+ compare(override_value, parsed_value, operator)
469
+ end
470
+ else
471
+ compare(override_value.to_s, value.to_s, operator)
472
+ end
473
+ when 'is_date_before', 'is_date_after'
474
+ override_date = PostHog::Utils.convert_to_datetime(override_value.to_s)
475
+ parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
476
+
477
+ parsed_date = PostHog::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
478
+
479
+ raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
480
+
481
+ if operator == 'is_date_before'
482
+ override_date < parsed_date
483
+ elsif operator == 'is_date_after'
484
+ override_date > parsed_date
485
+ end
486
+ else
487
+ raise InconclusiveMatchError, "Unknown operator: #{operator}"
488
+ end
489
+ end
490
+
491
+ def self.match_cohort(property, property_values, cohort_properties)
492
+ # Cohort properties are in the form of property groups like this:
493
+ # {
494
+ # "cohort_id" => {
495
+ # "type" => "AND|OR",
496
+ # "values" => [{
497
+ # "key" => "property_name", "value" => "property_value"
498
+ # }]
499
+ # }
500
+ # }
501
+ cohort_id = extract_value(property, :value).to_s
502
+ property_group = find_cohort_property(cohort_properties, cohort_id)
503
+
504
+ unless property_group
505
+ raise RequiresServerEvaluation,
506
+ "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
507
+ end
508
+
509
+ match_property_group(property_group, property_values, cohort_properties)
510
+ end
511
+
512
+ def self.match_property_group(property_group, property_values, cohort_properties)
513
+ return true if property_group.nil? || property_group.empty?
514
+
515
+ group_type = extract_value(property_group, :type)
516
+ properties = extract_value(property_group, :values)
517
+
518
+ return true if properties.nil? || properties.empty?
519
+
520
+ if nested_property_group?(properties)
521
+ match_nested_property_group(properties, group_type, property_values, cohort_properties)
522
+ else
523
+ match_regular_property_group(properties, group_type, property_values, cohort_properties)
524
+ end
525
+ end
526
+
527
+ def self.extract_value(hash, key)
528
+ return nil unless hash.is_a?(Hash)
529
+
530
+ hash[key] || hash[key.to_s]
531
+ end
532
+
533
+ def self.find_cohort_property(cohort_properties, cohort_id)
534
+ return nil unless cohort_properties.is_a?(Hash)
535
+
536
+ cohort_properties[cohort_id] || cohort_properties[cohort_id.to_sym]
537
+ end
538
+
539
+ def self.nested_property_group?(properties)
540
+ return false unless properties&.any?
541
+
542
+ first_property = properties[0]
543
+ return false unless first_property.is_a?(Hash)
544
+
545
+ first_property.key?(:values) || first_property.key?('values')
546
+ end
547
+
548
+ def self.match_nested_property_group(properties, group_type, property_values, cohort_properties)
549
+ case group_type
550
+ when 'AND'
551
+ properties.each do |property|
552
+ return false unless match_property_group(property, property_values, cohort_properties)
553
+ end
554
+ true
555
+ when 'OR'
556
+ properties.each do |property|
557
+ return true if match_property_group(property, property_values, cohort_properties)
558
+ end
559
+ false
560
+ else
561
+ raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
562
+ end
563
+ end
564
+
565
+ def self.match_regular_property_group(properties, group_type, property_values, cohort_properties)
566
+ # Validate group type upfront
567
+ raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
568
+
569
+ error_matching_locally = false
570
+
571
+ properties.each do |prop|
572
+ PostHog::Utils.symbolize_keys!(prop)
573
+
574
+ matches = match_property(prop, property_values, cohort_properties)
575
+
576
+ negated = prop[:negation] || false
577
+ final_result = negated ? !matches : matches
578
+
579
+ # Short-circuit based on group type
580
+ if group_type == 'AND'
581
+ return false unless final_result
582
+ elsif final_result # group_type == 'OR'
583
+ return true
584
+ end
585
+ rescue RequiresServerEvaluation
586
+ # Immediately propagate - this condition requires server-side data
587
+ raise
588
+ rescue InconclusiveMatchError => e
589
+ PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
590
+ error_matching_locally = true
591
+ end
592
+
593
+ raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
594
+
595
+ # If we reach here, return default based on group type
596
+ group_type == 'AND'
597
+ end
598
+
599
+ # Evaluates a flag dependency property according to the dependency chain algorithm.
600
+ #
601
+ # @param property [Hash] Flag property with type="flag" and dependency_chain
602
+ # @param evaluation_cache [Hash] Cache for storing evaluation results
603
+ # @param distinct_id [String] The distinct ID being evaluated
604
+ # @param properties [Hash] Person properties for evaluation
605
+ # @param cohort_properties [Hash] Cohort properties for evaluation
606
+ # @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
607
+ def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties)
608
+ if property[:operator] != 'flag_evaluates_to'
609
+ # Should never happen, but just in case
610
+ raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
611
+ end
612
+
613
+ if @feature_flags_by_key.nil? || evaluation_cache.nil?
614
+ # Cannot evaluate flag dependencies without required context
615
+ raise InconclusiveMatchError,
616
+ "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
617
+ 'without feature flags loaded or evaluation_cache'
618
+ end
619
+
620
+ # Check if dependency_chain is present - it should always be provided for flag dependencies
621
+ unless property.key?(:dependency_chain)
622
+ # Missing dependency_chain indicates malformed server data
623
+ raise InconclusiveMatchError,
624
+ "Flag dependency property for '#{property[:key] || 'unknown'}' " \
625
+ "is missing required 'dependency_chain' field"
626
+ end
627
+
628
+ dependency_chain = property[:dependency_chain]
629
+
630
+ # Handle circular dependency (empty chain means circular)
631
+ if dependency_chain.empty?
632
+ PostHog::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
633
+ raise InconclusiveMatchError,
634
+ "Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
635
+ end
636
+
637
+ # Evaluate all dependencies in the chain order
638
+ dependency_chain.each do |dep_flag_key|
639
+ unless evaluation_cache.key?(dep_flag_key)
640
+ # Need to evaluate this dependency first
641
+ dep_flag = @feature_flags_by_key[dep_flag_key]
642
+ if dep_flag.nil?
643
+ # Missing flag dependency - cannot evaluate locally
644
+ evaluation_cache[dep_flag_key] = nil
645
+ raise InconclusiveMatchError,
646
+ "Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
647
+ elsif !dep_flag[:active]
648
+ # Check if the flag is active (same check as in _compute_flag_locally)
649
+ evaluation_cache[dep_flag_key] = false
650
+ else
651
+ # Recursively evaluate the dependency using existing instance method
652
+ begin
653
+ dep_result = match_feature_flag_properties(
654
+ dep_flag,
655
+ distinct_id,
656
+ properties,
657
+ evaluation_cache,
658
+ cohort_properties
659
+ )
660
+ evaluation_cache[dep_flag_key] = dep_result
661
+ rescue InconclusiveMatchError => e
662
+ # If we can't evaluate a dependency, store nil and propagate the error
663
+ evaluation_cache[dep_flag_key] = nil
664
+ raise InconclusiveMatchError,
665
+ "Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
666
+ end
667
+ end
668
+ end
669
+
670
+ # Check the cached result
671
+ cached_result = evaluation_cache[dep_flag_key]
672
+ if cached_result.nil?
673
+ # Previously inconclusive - raise error again
674
+ raise InconclusiveMatchError,
675
+ "Flag dependency '#{dep_flag_key}' was previously inconclusive"
676
+ elsif !cached_result
677
+ # Definitive False result - dependency failed
678
+ return false
679
+ end
680
+ end
681
+
682
+ # Get the expected value of the immediate dependency and the actual value
683
+ expected_value = property[:value]
684
+ # The flag we want to evaluate is defined by :key which should ALSO be the last key in the dependency chain
685
+ actual_value = evaluation_cache[property[:key]]
686
+
687
+ self.class.matches_dependency_value(expected_value, actual_value)
688
+ end
689
+
690
+ def self.matches_dependency_value(expected_value, actual_value)
691
+ # Check if the actual flag value matches the expected dependency value.
692
+ #
693
+ # - String variant case: check for exact match or boolean true
694
+ # - Boolean case: must match expected boolean value
695
+ #
696
+ # @param expected_value [Object] The expected value from the property
697
+ # @param actual_value [Object] The actual value returned by the flag evaluation
698
+ # @return [Boolean] True if the values match according to flag dependency rules
699
+
700
+ # String variant case - check for exact match or boolean true
701
+ if actual_value.is_a?(String) && !actual_value.empty?
702
+ if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
703
+ # Any variant matches boolean true
704
+ return expected_value
705
+ elsif expected_value.is_a?(String)
706
+ # variants are case-sensitive, hence our comparison is too
707
+ return actual_value == expected_value
708
+ else
709
+ return false
710
+ end
711
+
712
+ # Boolean case - must match expected boolean value
713
+ elsif actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass)
714
+ return actual_value == expected_value if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
715
+ end
716
+
717
+ # Default case
718
+ false
719
+ end
720
+
721
+ private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
722
+ :match_nested_property_group, :match_regular_property_group
723
+
724
+ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
725
+ raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
726
+
727
+ return false unless flag[:active]
728
+
729
+ # Create evaluation cache for flag dependencies
730
+ evaluation_cache = {}
731
+
732
+ flag_filters = flag[:filters] || {}
733
+
734
+ aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
735
+ if aggregation_group_type_index.nil?
736
+ return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
737
+ end
738
+
739
+ group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
740
+
741
+ if group_name.nil?
742
+ logger.warn(
743
+ "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
744
+ )
745
+ # failover to `/flags/`
746
+ raise InconclusiveMatchError, 'Flag has unknown group type index'
747
+ end
748
+
749
+ group_name_symbol = group_name.to_sym
750
+
751
+ unless groups.key?(group_name_symbol)
752
+ # Group flags are never enabled if appropriate `groups` aren't passed in
753
+ # don't failover to `/flags/`, since response will be the same
754
+ logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
755
+ return false
756
+ end
757
+
758
+ focused_group_properties = group_properties[group_name_symbol]
759
+ match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
760
+ @cohorts)
761
+ end
762
+
763
+ def _compute_flag_payload_locally(key, match_value)
764
+ return nil if @feature_flags_by_key.nil?
765
+
766
+ response = nil
767
+ if [true, false].include? match_value
768
+ response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_s.to_sym)
769
+ elsif match_value.is_a? String
770
+ response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_sym)
771
+ end
772
+ response
773
+ end
774
+
775
+ def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
776
+ flag_filters = flag[:filters] || {}
777
+
778
+ flag_conditions = flag_filters[:groups] || []
779
+ is_inconclusive = false
780
+ result = nil
781
+
782
+ # NOTE: This NEEDS to be `each` because `each_key` breaks
783
+ flag_conditions.each do |condition|
784
+ if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties)
785
+ variant_override = condition[:variant]
786
+ flag_multivariate = flag_filters[:multivariate] || {}
787
+ flag_variants = flag_multivariate[:variants] || []
788
+ variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
789
+ variant_override
790
+ else
791
+ get_matching_variant(flag, distinct_id)
792
+ end
793
+ result = variant || true
794
+ break
795
+ end
796
+ rescue RequiresServerEvaluation
797
+ # Static cohort or other missing server-side data - must fallback to API
798
+ raise
799
+ rescue InconclusiveMatchError
800
+ # Evaluation error (bad regex, invalid date, missing property, etc.)
801
+ # Track that we had an inconclusive match, but try other conditions
802
+ is_inconclusive = true
803
+ end
804
+
805
+ if !result.nil?
806
+ return result
807
+ elsif is_inconclusive
808
+ raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
809
+ end
810
+
811
+ # We can only return False when all conditions are False
812
+ false
813
+ end
814
+
815
+ def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
816
+ rollout_percentage = condition[:rollout_percentage]
817
+
818
+ unless (condition[:properties] || []).empty?
819
+ unless condition[:properties].all? do |prop|
820
+ if prop[:type] == 'flag'
821
+ evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties)
822
+ else
823
+ FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
824
+ end
825
+ end
826
+ return false
827
+ end
828
+
829
+ return true if rollout_percentage.nil?
830
+ end
831
+
832
+ return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
833
+
834
+ true
835
+ end
836
+
837
+ # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
838
+ # Given the same distinct_id and key, it'll always return the same float. These floats are
839
+ # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
840
+ # we can do _hash(key, distinct_id) < 0.2
841
+ def _hash(key, distinct_id, salt = '')
842
+ hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
843
+ (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
844
+ end
845
+
846
+ def get_matching_variant(flag, distinct_id)
847
+ hash_value = _hash(flag[:key], distinct_id, 'variant')
848
+ matching_variant = variant_lookup_table(flag).find do |variant|
849
+ hash_value >= variant[:value_min] and hash_value < variant[:value_max]
850
+ end
851
+ matching_variant.nil? ? nil : matching_variant[:key]
852
+ end
853
+
854
+ def variant_lookup_table(flag)
855
+ lookup_table = []
856
+ value_min = 0
857
+ flag_filters = flag[:filters] || {}
858
+ variants = flag_filters[:multivariate] || {}
859
+ multivariates = variants[:variants] || []
860
+ multivariates.each do |variant|
861
+ value_max = value_min + (variant[:rollout_percentage].to_f / 100)
862
+ lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
863
+ value_min = value_max
864
+ end
865
+ lookup_table
866
+ end
867
+
868
+ def _load_feature_flags
869
+ begin
870
+ res = _request_feature_flag_definitions(etag: @flags_etag.value)
871
+ rescue StandardError => e
872
+ @on_error.call(-1, e.to_s)
873
+ return
874
+ end
875
+
876
+ # Handle 304 Not Modified - flags haven't changed, skip processing
877
+ # Only update ETag if the 304 response includes one
878
+ if res[:not_modified]
879
+ @flags_etag.value = res[:etag] if res[:etag]
880
+ logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
881
+ return
882
+ end
883
+
884
+ # Handle quota limits with 402 status
885
+ if res.is_a?(Hash) && res[:status] == 402
886
+ logger.warn(
887
+ '[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
888
+ 'Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
889
+ )
890
+ @feature_flags = Concurrent::Array.new
891
+ @feature_flags_by_key = {}
892
+ @group_type_mapping = Concurrent::Hash.new
893
+ @cohorts = Concurrent::Hash.new
894
+ @loaded_flags_successfully_once.make_false
895
+ @quota_limited.make_true
896
+ return
897
+ end
898
+
899
+ if res.key?(:flags)
900
+ # Only update ETag on successful responses with flag data
901
+ @flags_etag.value = res[:etag]
902
+
903
+ @feature_flags = res[:flags] || []
904
+ @feature_flags_by_key = {}
905
+ @feature_flags.each do |flag|
906
+ @feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
907
+ end
908
+ @group_type_mapping = res[:group_type_mapping] || {}
909
+ @cohorts = res[:cohorts] || {}
910
+
911
+ logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
912
+ @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
913
+ else
914
+ logger.debug "Failed to load feature flags: #{res}"
915
+ end
916
+ end
917
+
918
+ def _request_feature_flag_definitions(etag: nil)
919
+ uri = URI("#{@host}/api/feature_flag/local_evaluation")
920
+ uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
921
+ req = Net::HTTP::Get.new(uri)
922
+ req['Authorization'] = "Bearer #{@personal_api_key}"
923
+ req['If-None-Match'] = etag if etag
924
+
925
+ _request(uri, req, nil, include_etag: true)
926
+ end
927
+
928
+ def _request_feature_flag_evaluation(data = {})
929
+ uri = URI("#{@host}/flags/?v=2")
930
+ req = Net::HTTP::Post.new(uri)
931
+ req['Content-Type'] = 'application/json'
932
+ data['token'] = @project_api_key
933
+ req.body = data.to_json
934
+
935
+ _request(uri, req, @feature_flag_request_timeout_seconds)
936
+ end
937
+
938
+ def _request_remote_config_payload(flag_key)
939
+ uri = URI("#{@host}/api/projects/@current/feature_flags/#{flag_key}/remote_config")
940
+ uri.query = URI.encode_www_form([['token', @project_api_key]])
941
+ req = Net::HTTP::Get.new(uri)
942
+ req['Content-Type'] = 'application/json'
943
+ req['Authorization'] = "Bearer #{@personal_api_key}"
944
+
945
+ _request(uri, req, @feature_flag_request_timeout_seconds)
946
+ end
947
+
948
+ # rubocop:disable Lint/ShadowedException
949
+ def _request(uri, request_object, timeout = nil, include_etag: false)
950
+ request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
951
+ request_timeout = timeout || 10
952
+
953
+ begin
954
+ Net::HTTP.start(
955
+ uri.hostname,
956
+ uri.port,
957
+ use_ssl: uri.scheme == 'https',
958
+ read_timeout: request_timeout
959
+ ) do |http|
960
+ res = http.request(request_object)
961
+ status_code = res.code.to_i
962
+ etag = include_etag ? res['ETag'] : nil
963
+
964
+ # Handle 304 Not Modified - return special response indicating no change
965
+ if status_code == 304
966
+ logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
967
+ return { not_modified: true, etag: etag, status: status_code }
968
+ end
969
+
970
+ # Parse response body to hash
971
+ begin
972
+ response = JSON.parse(res.body, { symbolize_names: true })
973
+ # Only add status (and etag if requested) if response is a hash
974
+ extra_fields = { status: status_code }
975
+ extra_fields[:etag] = etag if include_etag
976
+ response = response.merge(extra_fields) if response.is_a?(Hash)
977
+ return response
978
+ rescue JSON::ParserError
979
+ # Handle case when response isn't valid JSON
980
+ error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
981
+ error_response[:etag] = etag if include_etag
982
+ return error_response
983
+ end
984
+ end
985
+ rescue Timeout::Error,
986
+ Errno::EINVAL,
987
+ Errno::ECONNRESET,
988
+ EOFError,
989
+ Net::HTTPBadResponse,
990
+ Net::HTTPHeaderSyntaxError,
991
+ Net::ReadTimeout,
992
+ Net::WriteTimeout,
993
+ Net::ProtocolError
994
+ logger.debug("Unable to complete request to #{uri}")
995
+ raise
996
+ end
997
+ end
998
+ # rubocop:enable Lint/ShadowedException
999
+
1000
+ def _mask_tokens_in_url(url)
1001
+ url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
1002
+ end
1003
+ end
1004
+ end