posthog-ruby 3.28.4 → 3.29.1

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.
@@ -9,19 +9,12 @@ require 'posthog/defaults'
9
9
  require 'posthog/backoff_policy'
10
10
  require 'posthog/feature_flag'
11
11
  require 'posthog/flag_definition_cache'
12
+ require 'posthog/unresolved_flag_reason'
13
+ require 'posthog/flag_evaluation_errors'
12
14
  require 'digest'
15
+ require 'set'
13
16
 
14
17
  module PostHog
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
18
  # Polls and evaluates feature flag definitions for {PostHog::Client}.
26
19
  #
27
20
  # @api private
@@ -29,6 +22,22 @@ module PostHog
29
22
  include PostHog::Logging
30
23
  include PostHog::Utils
31
24
 
25
+ # When several causes apply to one flag, the spec reports the first reason in this order, so
26
+ # every SDK picks the same reason whatever order it inspects the filters in.
27
+ UNRESOLVED_REASON_PRECEDENCE = [
28
+ UnresolvedFlagReason::EXPERIENCE_CONTINUITY,
29
+ UnresolvedFlagReason::UNSUPPORTED_DEFINITION,
30
+ UnresolvedFlagReason::UNRESOLVED_DEPENDENCY,
31
+ UnresolvedFlagReason::MISSING_CONTEXT
32
+ ].freeze
33
+
34
+ # Operator groups that share one branch in both match_property_value and assert_filter_supported,
35
+ # so the load-time check stays in step with evaluation.
36
+ REGEX_OPERATORS = %w[regex not_regex].freeze
37
+ NUMERIC_OPERATORS = %w[gt gte lt lte].freeze
38
+ DATE_OPERATORS = %w[is_date_before is_date_after].freeze
39
+ SEMVER_COMPARISON_OPERATORS = %w[semver_eq semver_neq semver_gt semver_gte semver_lt semver_lte].freeze
40
+
32
41
  # @param polling_interval [Integer, nil] Seconds between local feature flag definition polls.
33
42
  # Defaults to {Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS}.
34
43
  # @param secret_key [String, nil] Credential used to fetch local evaluation definitions. Accepts either a
@@ -86,6 +95,8 @@ module PostHog
86
95
  # from the top-level `minimal_flag_called_events` key of the local
87
96
  # evaluation definitions payload. false when the server does not send it.
88
97
  @minimal_flag_called_events = false
98
+ # Definition fingerprint per flag key already warned about as never locally resolvable.
99
+ @warned_unresolvable_flags = {}
89
100
  @definition_snapshot = Concurrent::AtomicReference.new({
90
101
  flags: @feature_flags, flags_by_key: @feature_flags_by_key,
91
102
  group_type_mapping: @group_type_mapping, cohorts: @cohorts,
@@ -139,6 +150,9 @@ module PostHog
139
150
  only_evaluate_locally = false,
140
151
  raise_on_error = false
141
152
  )
153
+ groups, person_properties, group_properties = normalize_flag_evaluation_inputs(
154
+ groups, person_properties, group_properties
155
+ )
142
156
  # TODO: Convert to options hash for easier argument passing
143
157
  flags_data = get_all_flags_and_payloads(
144
158
  distinct_id,
@@ -235,14 +249,6 @@ module PostHog
235
249
  load_feature_flags
236
250
  snapshot ||= _evaluation_snapshot
237
251
 
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
252
  response = nil
247
253
  payload = nil
248
254
  feature_flag = snapshot[:flags_by_key]&.[](key)
@@ -485,6 +491,12 @@ module PostHog
485
491
 
486
492
  # Class methods
487
493
 
494
+ def self.pick_unresolved_reason(current, reason)
495
+ return reason if current.nil?
496
+
497
+ UNRESOLVED_REASON_PRECEDENCE.index(reason) < UNRESOLVED_REASON_PRECEDENCE.index(current) ? reason : current
498
+ end
499
+
488
500
  def self.compare(lhs, rhs, operator)
489
501
  case operator
490
502
  when 'gt'
@@ -547,33 +559,36 @@ module PostHog
547
559
  end
548
560
 
549
561
  # Parse a single semver numeric identifier, rejecting empty, non-digit, or
550
- # leading-zero values per semver 2.0.0 §2.
551
- def self.parse_semver_numeric(part)
552
- raise InconclusiveMatchError, 'Invalid semver format' if part.nil? || part.empty? || part !~ /^\d+$/
562
+ # leading-zero values per semver 2.0.0 §2. `reason` names the owner of the value:
563
+ # UNSUPPORTED_DEFINITION for a filter value, MISSING_CONTEXT for a value the call passed.
564
+ def self.parse_semver_numeric(part, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
565
+ if part.nil? || part.empty? || part !~ /^\d+$/
566
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason)
567
+ end
553
568
  # Semver 2.0.0 §2: numeric identifiers MUST NOT include leading zeros.
554
- raise InconclusiveMatchError, 'Invalid semver format' if part.length > 1 && part[0] == '0'
569
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if part.length > 1 && part[0] == '0'
555
570
 
556
571
  part.to_i
557
572
  end
558
573
 
559
574
  # Parse a semver string into a comparable [major, minor, patch] integer array.
560
575
  # Handles v-prefix, whitespace, pre-release suffixes. Defaults missing components to 0.
561
- def self.parse_semver(value)
562
- raise InconclusiveMatchError, 'Invalid semver format' if value.nil?
576
+ def self.parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
577
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if value.nil?
563
578
 
564
579
  text = value.to_s.strip.sub(/^[vV]/, '')
565
580
 
566
- raise InconclusiveMatchError, 'Invalid semver format' if text.empty?
581
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if text.empty?
567
582
 
568
583
  # Strip pre-release and build metadata suffixes
569
584
  text = text.split('-')[0].split('+')[0]
570
585
  parts = text.split('.')
571
586
 
572
- raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty?
587
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if parts.empty? || parts[0].to_s.empty?
573
588
 
574
- major = parse_semver_numeric(parts[0])
575
- minor = parts.length > 1 ? parse_semver_numeric(parts[1]) : 0
576
- patch = parts.length > 2 ? parse_semver_numeric(parts[2]) : 0
589
+ major = parse_semver_numeric(parts[0], reason: reason)
590
+ minor = parts.length > 1 ? parse_semver_numeric(parts[1], reason: reason) : 0
591
+ patch = parts.length > 2 ? parse_semver_numeric(parts[2], reason: reason) : 0
577
592
 
578
593
  [major, minor, patch]
579
594
  end
@@ -628,12 +643,16 @@ module PostHog
628
643
  cleaned = value.to_s.strip.sub(/^[vV]/, '').gsub('*', '').chomp('.')
629
644
  parts = cleaned.split('.').reject(&:empty?)
630
645
 
631
- raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty?
646
+ if parts.empty?
647
+ raise InconclusiveMatchError.new('Invalid semver wildcard format',
648
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
649
+ end
632
650
 
633
651
  numeric = parts.map do |part|
634
652
  parse_semver_numeric(part)
635
653
  rescue InconclusiveMatchError
636
- raise InconclusiveMatchError, 'Invalid semver wildcard format'
654
+ raise InconclusiveMatchError.new('Invalid semver wildcard format',
655
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
637
656
  end
638
657
 
639
658
  major = numeric[0]
@@ -710,33 +729,39 @@ module PostHog
710
729
  def self.match_property(property, property_values, cohort_properties = {}, property_matching_version: 1)
711
730
  # only looks for matches where key exists in property_values
712
731
 
713
- PostHog::Utils.symbolize_keys! property
714
- PostHog::Utils.symbolize_keys! property_values
715
-
716
732
  # Handle cohort properties
717
733
  if extract_value(property, :type) == 'cohort'
718
734
  return match_cohort(property, property_values, cohort_properties,
719
735
  property_matching_version: property_matching_version)
720
736
  end
721
737
 
722
- key = property[:key].to_sym
723
- value = property[:value]
724
- operator = property[:operator] || 'exact'
725
-
726
- if !property_values.key?(key)
727
- raise InconclusiveMatchError, "Property #{key} not found in property_values"
728
- elsif operator == 'is_not_set'
729
- return false
738
+ begin
739
+ match_property_value(property, property_values, property_matching_version)
740
+ rescue InconclusiveMatchError => e
741
+ # An unusable filter outranks missing context, so check the filter before blaming the context.
742
+ assert_filter_supported(property) if e.reason == UnresolvedFlagReason::MISSING_CONTEXT
743
+ raise
730
744
  end
745
+ end
731
746
 
732
- override_value = property_values[key]
747
+ def self.match_property_value(property, property_values, property_matching_version)
748
+ key = (property[:key] || property['key']).to_sym
749
+ value = property.key?(:value) ? property[:value] : property['value']
750
+ operator = property[:operator] || property['operator'] || 'exact'
751
+ override_value = property_values.fetch(key) do
752
+ property_values.fetch(key.to_s) do
753
+ raise InconclusiveMatchError.new("Property #{key} not found in property_values",
754
+ reason: UnresolvedFlagReason::MISSING_CONTEXT)
755
+ end
756
+ end
757
+ return false if operator == 'is_not_set'
733
758
 
734
759
  case operator
735
760
  when 'exact', 'is_not'
736
761
  matches = exact_property_match?(value, override_value, property_matching_version)
737
762
  operator == 'exact' ? matches : !matches
738
763
  when 'is_set'
739
- property_values.key?(key)
764
+ true
740
765
  when 'icontains'
741
766
  string_operand(override_value).downcase(:ascii).include?(value.to_s.downcase(:ascii))
742
767
  when 'not_icontains'
@@ -749,34 +774,32 @@ module PostHog
749
774
  string_operand(override_value).downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
750
775
  when 'not_ends_with'
751
776
  !string_operand(override_value).downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
752
- when 'regex'
753
- PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(string_operand(override_value)).nil?
754
- when 'not_regex'
755
- PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(string_operand(override_value)).nil?
756
- when 'gt', 'gte', 'lt', 'lte'
757
- parsed_value = parse_finite_number(value)
758
- raise InconclusiveMatchError, 'Invalid numeric condition' if parsed_value.nil?
777
+ when *REGEX_OPERATORS
778
+ matched = match_regex(value.to_s, string_operand(override_value))
779
+ return false if matched.nil?
759
780
 
781
+ operator == 'regex' ? matched : !matched
782
+ when *NUMERIC_OPERATORS
783
+ parsed_value = parse_numeric_filter(value)
760
784
  parsed_override = parse_finite_number(override_value)
761
785
  return false if parsed_override.nil?
762
786
 
763
787
  compare(parsed_override, parsed_value, operator)
764
- when 'is_date_before', 'is_date_after'
765
- override_date = PostHog::Utils.convert_to_datetime(override_value.to_s)
766
- parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
767
-
768
- parsed_date = PostHog::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
769
-
770
- raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
788
+ when *DATE_OPERATORS
789
+ # Parses the filter before the context, so a bad filter reports unsupported_definition even
790
+ # when the context value is bad too.
791
+ parsed_date = parse_date_filter(value)
792
+ override_date = PostHog::Utils.convert_to_datetime(override_value.to_s,
793
+ reason: UnresolvedFlagReason::MISSING_CONTEXT)
771
794
 
772
795
  if operator == 'is_date_before'
773
796
  override_date < parsed_date
774
797
  elsif operator == 'is_date_after'
775
798
  override_date > parsed_date
776
799
  end
777
- when 'semver_eq', 'semver_neq', 'semver_gt', 'semver_gte', 'semver_lt', 'semver_lte'
778
- override_parsed = parse_semver(override_value)
779
- flag_parsed = parse_semver(value)
800
+ when *SEMVER_COMPARISON_OPERATORS
801
+ flag_parsed = parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
802
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
780
803
 
781
804
  case operator
782
805
  when 'semver_eq'
@@ -793,21 +816,227 @@ module PostHog
793
816
  (override_parsed <=> flag_parsed) <= 0
794
817
  end
795
818
  when 'semver_tilde'
796
- override_parsed = parse_semver(override_value)
797
819
  lower, upper = semver_tilde_bounds(value)
820
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
798
821
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
799
822
  when 'semver_caret'
800
- override_parsed = parse_semver(override_value)
801
823
  lower, upper = semver_caret_bounds(value)
824
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
802
825
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
803
826
  when 'semver_wildcard'
804
- override_parsed = parse_semver(override_value)
805
827
  lower, upper = semver_wildcard_bounds(value)
828
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
806
829
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
807
830
  else
808
- raise InconclusiveMatchError, "Unknown operator: #{operator}"
831
+ raise InconclusiveMatchError.new("Unknown operator: #{operator}",
832
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
833
+ end
834
+ end
835
+ private_class_method :match_property_value
836
+
837
+ # Raises an unsupported_definition error when local evaluation cannot use the filter, whatever
838
+ # the call passes: an unknown operator, or a filter value that the operator cannot parse.
839
+ def self.assert_filter_supported(property)
840
+ value = property.key?(:value) ? property[:value] : property['value']
841
+ operator = property[:operator] || property['operator'] || 'exact'
842
+ case operator
843
+ when 'exact', 'is_not', 'is_set', 'is_not_set', 'icontains', 'not_icontains', 'starts_with',
844
+ 'not_starts_with', 'ends_with', 'not_ends_with'
845
+ nil
846
+ when *REGEX_OPERATORS
847
+ # An empty input is the most portable one, so this raises only for a pattern that needs the
848
+ # server whatever the value. Compile without matching, so a server-supplied pattern never runs
849
+ # while definitions load.
850
+ pattern = value.to_s
851
+ compile_regex(pattern, '') unless pattern.empty?
852
+ when *NUMERIC_OPERATORS
853
+ parse_numeric_filter(value)
854
+ when *DATE_OPERATORS
855
+ parse_date_filter(value)
856
+ when *SEMVER_COMPARISON_OPERATORS
857
+ parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
858
+ when 'semver_tilde'
859
+ semver_tilde_bounds(value)
860
+ when 'semver_caret'
861
+ semver_caret_bounds(value)
862
+ when 'semver_wildcard'
863
+ semver_wildcard_bounds(value)
864
+ else
865
+ raise InconclusiveMatchError.new("Unknown operator: #{operator}",
866
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
867
+ end
868
+ end
869
+
870
+ def self.parse_numeric_filter(value)
871
+ parsed_value = parse_finite_number(value)
872
+ if parsed_value.nil?
873
+ raise InconclusiveMatchError.new('Invalid numeric condition',
874
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
875
+ end
876
+
877
+ parsed_value
878
+ end
879
+ private_class_method :parse_numeric_filter
880
+
881
+ def self.parse_date_filter(value)
882
+ parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s) ||
883
+ PostHog::Utils.convert_to_datetime(value.to_s,
884
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
885
+ return parsed_date if parsed_date
886
+
887
+ raise InconclusiveMatchError.new('Invalid date format', reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
888
+ end
889
+ private_class_method :parse_date_filter
890
+
891
+ def self.match_regex(pattern, value)
892
+ return true if pattern.empty?
893
+
894
+ regex = compile_regex(pattern, value)
895
+ return nil if regex.nil?
896
+
897
+ !regex.match(value).nil?
898
+ rescue InconclusiveMatchError
899
+ raise
900
+ rescue StandardError
901
+ raise InconclusiveMatchError.new('Regex execution requires server evaluation',
902
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
903
+ end
904
+ private_class_method :match_regex
905
+
906
+ # Returns nil for a pattern that Ruby cannot compile.
907
+ def self.compile_regex(pattern, value)
908
+ unless portable_regex?(pattern, value)
909
+ raise InconclusiveMatchError.new('Regex compatibility requires server evaluation',
910
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
911
+ end
912
+
913
+ Regexp.new(pattern)
914
+ rescue RegexpError => e
915
+ # Ruby's repeat-count limit is smaller than the service's syntax limit.
916
+ if e.message.include?('too big')
917
+ raise InconclusiveMatchError.new('Regex compilation requires server evaluation',
918
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
919
+ end
920
+
921
+ nil
922
+ rescue InconclusiveMatchError
923
+ raise
924
+ rescue StandardError
925
+ # For example, a pattern with an invalid byte sequence.
926
+ raise InconclusiveMatchError.new('Regex compatibility requires server evaluation',
927
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
928
+ end
929
+ private_class_method :compile_regex
930
+
931
+ # Native compilation alone cannot establish compatibility with the flags service's Rust engine.
932
+ # Keep ordinary scalar matching local, but defer engine-specific syntax and backtracking assertions.
933
+ def self.portable_regex?(pattern, value)
934
+ # Ruby string offsets repeatedly traverse multibyte prefixes; tokenize once for constant-time lookahead.
935
+ characters = pattern.chars
936
+ ascii_input = value.ascii_only?
937
+ newline_input = value.include?("\n")
938
+ class_start = nil
939
+ previous_token = nil
940
+ index = 0
941
+ while index < characters.length
942
+ character = characters[index]
943
+ if character == '\\'
944
+ index += 1
945
+ escaped = characters[index]
946
+ break if escaped.nil?
947
+
948
+ if escaped == 'x'
949
+ hex = characters[index + 1, 2]&.join
950
+ return false unless hex&.match?(/\A[0-9a-fA-F]{2}\z/) && hex.to_i(16) < 128
951
+
952
+ index += 2
953
+ elsif 'dDsSwW'.include?(escaped)
954
+ # Ruby shorthand classes are ASCII-oriented, unlike the service's Unicode classes.
955
+ return false unless ascii_input
956
+ elsif class_start.nil?
957
+ return false if 'bB'.include?(escaped) # Even short ASCII inputs can exceed the service's boundary budget.
958
+ return false unless 'Azfnrtv'.include?(escaped) || escaped.match?(/[\x20-\x2f:;=?@\x5b-\x60\x7b-\x7e]/)
959
+ else
960
+ return false unless 'bfnrtv\\.^$|?*+(){}[]-'.include?(escaped)
961
+ end
962
+ previous_token = class_start.nil? && %w[A z].include?(escaped) ? :assertion : :literal
963
+ elsif character == '['
964
+ return false unless class_start.nil?
965
+
966
+ class_start = index
967
+ elsif character == ']' && class_start
968
+ content_start = class_start + (characters[class_start + 1] == '^' ? 2 : 1)
969
+ return false if index == content_start
970
+
971
+ class_start = nil
972
+ previous_token = :literal
973
+ elsif class_start
974
+ return false if %w[&& -- ~~].include?(characters[index, 2].join)
975
+ else
976
+ token = character
977
+ if character == '(' && characters[index + 1] == '?'
978
+ return false unless characters[index + 2] == ':'
979
+
980
+ index += 2
981
+ elsif character == '{'
982
+ repetition_end = regex_repetition_end(characters, index)
983
+ return false unless repetition_end && ![nil, '|', '('].include?(previous_token)
984
+ return false if %i[assertion empty_group repetition lazy_repetition
985
+ bounded_repetition].include?(previous_token)
986
+
987
+ index = repetition_end
988
+ token = :bounded_repetition
989
+ elsif '*+?'.include?(character)
990
+ # A leading quantifier is invalid in both engines, regardless of the remaining syntax.
991
+ return true if [nil, '|', '('].include?(previous_token)
992
+ return false if %i[assertion empty_group].include?(previous_token)
993
+
994
+ repeated = %i[repetition lazy_repetition bounded_repetition].include?(previous_token)
995
+ return false if repeated && !(character == '?' && previous_token == :repetition)
996
+
997
+ token = :repetition
998
+ token = :lazy_repetition if repeated
999
+ elsif '^$'.include?(character)
1000
+ # Ruby ^/$ are line anchors even without a multiline option; Rust's defaults are absolute.
1001
+ return false if newline_input
1002
+
1003
+ token = :assertion
1004
+ elsif character == ')' && ['(', :empty_group].include?(previous_token)
1005
+ token = :empty_group
1006
+ end
1007
+ previous_token = token
1008
+ end
1009
+ index += 1
1010
+ end
1011
+ true
1012
+ end
1013
+ private_class_method :portable_regex?
1014
+
1015
+ def self.regex_repetition_end(characters, index)
1016
+ index += 1
1017
+ return nil unless characters[index]&.match?(/\A[0-9]\z/)
1018
+
1019
+ count = 0
1020
+ comma = false
1021
+ while index < characters.length
1022
+ character = characters[index]
1023
+ digit = character.ord - 48
1024
+ if digit.between?(0, 9)
1025
+ count = (count * 10) + digit
1026
+ return nil if count > 0xffffffff
1027
+ elsif character == ',' && !comma
1028
+ comma = true
1029
+ count = 0
1030
+ elsif character == '}'
1031
+ return index
1032
+ else
1033
+ return nil
1034
+ end
1035
+ index += 1
809
1036
  end
1037
+ nil
810
1038
  end
1039
+ private_class_method :regex_repetition_end
811
1040
 
812
1041
  def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
813
1042
  # Cohort properties are in the form of property groups like this:
@@ -823,8 +1052,10 @@ module PostHog
823
1052
  property_group = find_cohort_property(cohort_properties, cohort_id)
824
1053
 
825
1054
  unless property_group
826
- raise RequiresServerEvaluation,
827
- "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
1055
+ raise RequiresServerEvaluation.new(
1056
+ "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation",
1057
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1058
+ )
828
1059
  end
829
1060
 
830
1061
  match_property_group(property_group, property_values, cohort_properties,
@@ -840,11 +1071,16 @@ module PostHog
840
1071
  return true if properties.nil? || properties.empty?
841
1072
 
842
1073
  if nested_property_group?(properties)
843
- match_nested_property_group(properties, group_type, property_values, cohort_properties,
844
- property_matching_version: property_matching_version)
1074
+ property_group_items_match?(properties, group_type) do |group|
1075
+ match_property_group(group, property_values, cohort_properties,
1076
+ property_matching_version: property_matching_version)
1077
+ end
845
1078
  else
846
- match_regular_property_group(properties, group_type, property_values, cohort_properties,
847
- property_matching_version: property_matching_version)
1079
+ property_group_items_match?(properties, group_type) do |prop|
1080
+ matches = match_property(prop, property_values, cohort_properties,
1081
+ property_matching_version: property_matching_version)
1082
+ extract_value(prop, :negation) ? !matches : matches
1083
+ end
848
1084
  end
849
1085
  end
850
1086
 
@@ -869,59 +1105,32 @@ module PostHog
869
1105
  first_property.key?(:values) || first_property.key?('values')
870
1106
  end
871
1107
 
872
- def self.match_nested_property_group(properties, group_type, property_values, cohort_properties,
873
- property_matching_version: 1)
874
- case group_type
875
- when 'AND'
876
- properties.each do |property|
877
- return false unless match_property_group(property, property_values, cohort_properties,
878
- property_matching_version: property_matching_version)
879
- end
880
- true
881
- when 'OR'
882
- properties.each do |property|
883
- return true if match_property_group(property, property_values, cohort_properties,
884
- property_matching_version: property_matching_version)
885
- end
886
- false
887
- else
888
- raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
1108
+ # Combines the items of a cohort property group with AND or OR. The block evaluates one item.
1109
+ # RequiresServerEvaluation is not an InconclusiveMatchError, so it propagates at once.
1110
+ def self.property_group_items_match?(items, group_type)
1111
+ unless %w[AND OR].include?(group_type)
1112
+ raise InconclusiveMatchError.new("Unknown property group type: #{group_type}",
1113
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
889
1114
  end
890
- end
891
-
892
- def self.match_regular_property_group(properties, group_type, property_values, cohort_properties,
893
- property_matching_version: 1)
894
- # Validate group type upfront
895
- raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
896
-
897
- error_matching_locally = false
898
-
899
- properties.each do |prop|
900
- PostHog::Utils.symbolize_keys!(prop)
901
-
902
- matches = match_property(prop, property_values, cohort_properties,
903
- property_matching_version: property_matching_version)
904
1115
 
905
- negated = prop[:negation] || false
906
- final_result = negated ? !matches : matches
907
-
908
- # Short-circuit based on group type
1116
+ inconclusive_reason = nil
1117
+ items.each do |item|
1118
+ matches = yield(item)
909
1119
  if group_type == 'AND'
910
- return false unless final_result
911
- elsif final_result # group_type == 'OR'
1120
+ return false unless matches
1121
+ elsif matches
912
1122
  return true
913
1123
  end
914
- rescue RequiresServerEvaluation
915
- # Immediately propagate - this condition requires server-side data
916
- raise
917
1124
  rescue InconclusiveMatchError => e
918
- PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
919
- error_matching_locally = true
1125
+ PostHog::Logging.logger&.debug("Failed to compute property #{item} locally: #{e}")
1126
+ inconclusive_reason = pick_unresolved_reason(inconclusive_reason, e.reason)
920
1127
  end
921
1128
 
922
- raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
1129
+ unless inconclusive_reason.nil?
1130
+ raise InconclusiveMatchError.new("can't match cohort without a given cohort property value",
1131
+ reason: inconclusive_reason)
1132
+ end
923
1133
 
924
- # If we reach here, return default based on group type
925
1134
  group_type == 'AND'
926
1135
  end
927
1136
 
@@ -935,48 +1144,38 @@ module PostHog
935
1144
  # @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
936
1145
  def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties,
937
1146
  snapshot: _evaluation_snapshot)
938
- if property[:operator] != 'flag_evaluates_to'
939
- # Should never happen, but just in case
940
- raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
941
- end
942
-
943
1147
  if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
944
1148
  # Cannot evaluate flag dependencies without required context
945
- raise InconclusiveMatchError,
946
- "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
947
- 'without feature flags loaded or evaluation_cache'
1149
+ raise InconclusiveMatchError.new(
1150
+ "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
1151
+ 'without feature flags loaded or evaluation_cache',
1152
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1153
+ )
948
1154
  end
949
1155
 
950
- # Check if dependency_chain is present - it should always be provided for flag dependencies
951
- unless property.key?(:dependency_chain)
952
- # Missing dependency_chain indicates malformed server data
953
- raise InconclusiveMatchError,
954
- "Flag dependency property for '#{property[:key] || 'unknown'}' " \
955
- "is missing required 'dependency_chain' field"
1156
+ # A dependency with no definition outranks one that is only inconclusive, so this checks the whole
1157
+ # chain before evaluating any of it. Nothing is cached for it, so a later lookup reports the same.
1158
+ definition_error = FeatureFlagsPoller.flag_dependency_definition_error(property, snapshot[:flags_by_key])
1159
+ unless definition_error.nil?
1160
+ raise InconclusiveMatchError.new(definition_error, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
956
1161
  end
957
1162
 
958
1163
  dependency_chain = property[:dependency_chain]
959
1164
 
960
- # Handle circular dependency (empty chain means circular)
961
- if dependency_chain.empty?
962
- PostHog::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
963
- raise InconclusiveMatchError,
964
- "Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
965
- end
966
-
967
1165
  # Evaluate all dependencies in the chain order
968
1166
  dependency_chain.each do |dep_flag_key|
969
1167
  unless evaluation_cache.key?(dep_flag_key)
970
1168
  # Need to evaluate this dependency first
971
1169
  dep_flag = snapshot[:flags_by_key][dep_flag_key]
972
- if dep_flag.nil?
973
- # Missing flag dependency - cannot evaluate locally
974
- evaluation_cache[dep_flag_key] = nil
975
- raise InconclusiveMatchError,
976
- "Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
977
- elsif !dep_flag[:active]
1170
+ if !dep_flag[:active]
978
1171
  # Check if the flag is active (same check as in _compute_flag_locally)
979
1172
  evaluation_cache[dep_flag_key] = false
1173
+ elsif dep_flag[:ensure_experience_continuity]
1174
+ evaluation_cache[dep_flag_key] = nil
1175
+ raise InconclusiveMatchError.new(
1176
+ "Cannot evaluate flag dependency '#{dep_flag_key}' - flag has experience continuity enabled",
1177
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY
1178
+ )
980
1179
  else
981
1180
  # Recursively evaluate the dependency using existing instance method
982
1181
  begin
@@ -989,11 +1188,16 @@ module PostHog
989
1188
  snapshot: snapshot
990
1189
  )
991
1190
  evaluation_cache[dep_flag_key] = dep_result
1191
+ rescue RequiresServerEvaluation => e
1192
+ # The dependent flag needs the server too. Nothing is cached, so a later lookup of this
1193
+ # dependency also requires the server instead of reading it as merely inconclusive.
1194
+ raise RequiresServerEvaluation.new("Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}",
1195
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
992
1196
  rescue InconclusiveMatchError => e
993
1197
  # If we can't evaluate a dependency, store nil and propagate the error
994
1198
  evaluation_cache[dep_flag_key] = nil
995
- raise InconclusiveMatchError,
996
- "Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
1199
+ raise InconclusiveMatchError.new("Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}",
1200
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
997
1201
  end
998
1202
  end
999
1203
  end
@@ -1002,8 +1206,8 @@ module PostHog
1002
1206
  cached_result = evaluation_cache[dep_flag_key]
1003
1207
  if cached_result.nil?
1004
1208
  # Previously inconclusive - raise error again
1005
- raise InconclusiveMatchError,
1006
- "Flag dependency '#{dep_flag_key}' was previously inconclusive"
1209
+ raise InconclusiveMatchError.new("Flag dependency '#{dep_flag_key}' was previously inconclusive",
1210
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
1007
1211
  elsif !cached_result
1008
1212
  # Definitive False result - dependency failed
1009
1213
  return false
@@ -1049,8 +1253,96 @@ module PostHog
1049
1253
  false
1050
1254
  end
1051
1255
 
1256
+ # The unresolved reason that comes from the flag's definition alone, so it is known when
1257
+ # definitions load. Matches the reason local evaluation reports for the same cause.
1258
+ def self.definition_unresolved_reason(flag, flags_by_key, cohorts, group_type_mapping)
1259
+ return nil unless flag[:active]
1260
+ return UnresolvedFlagReason::EXPERIENCE_CONTINUITY if flag[:ensure_experience_continuity]
1261
+
1262
+ flag_filters = flag[:filters] || {}
1263
+ aggregation = flag_filters[:aggregation_group_type_index]
1264
+ if !aggregation.nil? && group_type_mapping[aggregation.to_s.to_sym].nil?
1265
+ return UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1266
+ end
1267
+
1268
+ unsupported = (flag_filters[:groups] || []).any? do |condition|
1269
+ (condition[:properties] || []).any? do |property|
1270
+ unsupported_property?(property, flags_by_key, cohorts, Set.new)
1271
+ end
1272
+ end
1273
+ unsupported ? UnresolvedFlagReason::UNSUPPORTED_DEFINITION : nil
1274
+ end
1275
+
1276
+ # Why a flag dependency filter can never evaluate locally, whatever the call passes, or nil.
1277
+ # Evaluation and the load-time warning both use it, so the warning matches the reason evaluation reports.
1278
+ def self.flag_dependency_definition_error(property, flags_by_key)
1279
+ operator = property[:operator]
1280
+ return "Operator #{operator} not supported for flag dependencies" if operator != 'flag_evaluates_to'
1281
+
1282
+ key = property[:key] || 'unknown'
1283
+ dependency_chain = property[:dependency_chain]
1284
+ unless property.key?(:dependency_chain)
1285
+ return "Flag dependency property for '#{key}' is missing required 'dependency_chain' field"
1286
+ end
1287
+ unless dependency_chain.is_a?(Array)
1288
+ return "Flag dependency property for '#{key}' has an invalid 'dependency_chain' field"
1289
+ end
1290
+ return "Circular dependency detected for flag '#{key}'" if dependency_chain.empty?
1291
+
1292
+ missing_dependency = dependency_chain.find { |dep_flag_key| flags_by_key[dep_flag_key].nil? }
1293
+ return nil if missing_dependency.nil?
1294
+
1295
+ "Cannot evaluate flag dependency '#{missing_dependency}' - flag not found in local flags"
1296
+ end
1297
+
1298
+ def self.unsupported_property?(property, flags_by_key, cohorts, visited_cohort_ids)
1299
+ case property[:type]
1300
+ when 'flag'
1301
+ !flag_dependency_definition_error(property, flags_by_key).nil?
1302
+ when 'cohort'
1303
+ cohort_id = property[:value].to_s
1304
+ property_group = find_cohort_property(cohorts, cohort_id)
1305
+ return true if property_group.nil?
1306
+ return false unless visited_cohort_ids.add?(cohort_id)
1307
+
1308
+ unsupported_property_group?(property_group, flags_by_key, cohorts, visited_cohort_ids)
1309
+ else
1310
+ unsupported_filter?(property)
1311
+ end
1312
+ end
1313
+
1314
+ def self.unsupported_property_group?(property_group, flags_by_key, cohorts, visited_cohort_ids)
1315
+ properties = extract_value(property_group, :values)
1316
+ return false if properties.nil? || properties.empty?
1317
+ return true unless %w[AND OR].include?(extract_value(property_group, :type))
1318
+
1319
+ if nested_property_group?(properties)
1320
+ return properties.any? do |group|
1321
+ unsupported_property_group?(group, flags_by_key, cohorts, visited_cohort_ids)
1322
+ end
1323
+ end
1324
+
1325
+ properties.any? do |property|
1326
+ filter = PostHog::Utils.symbolize_keys(property)
1327
+ if filter[:type] == 'cohort'
1328
+ unsupported_property?(filter, flags_by_key, cohorts, visited_cohort_ids)
1329
+ else
1330
+ # Like evaluation, a cohort group checks a flag filter as an ordinary property.
1331
+ unsupported_filter?(filter)
1332
+ end
1333
+ end
1334
+ end
1335
+
1336
+ def self.unsupported_filter?(property)
1337
+ assert_filter_supported(property)
1338
+ false
1339
+ rescue InconclusiveMatchError
1340
+ true
1341
+ end
1342
+
1052
1343
  private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
1053
- :match_nested_property_group, :match_regular_property_group
1344
+ :property_group_items_match?, :assert_filter_supported,
1345
+ :unsupported_property?, :unsupported_property_group?, :unsupported_filter?
1054
1346
 
1055
1347
  # Publish/capture all matching state together so a poll cannot switch semantics
1056
1348
  # (or dependency/cohort definitions) halfway through one local evaluation.
@@ -1060,10 +1352,14 @@ module PostHog
1060
1352
 
1061
1353
  def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {},
1062
1354
  snapshot: _evaluation_snapshot)
1063
- raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
1064
-
1355
+ # An inactive flag is false whatever its experience continuity setting.
1065
1356
  return false unless flag[:active]
1066
1357
 
1358
+ if flag[:ensure_experience_continuity]
1359
+ raise RequiresServerEvaluation.new('Flag has experience continuity enabled',
1360
+ reason: UnresolvedFlagReason::EXPERIENCE_CONTINUITY)
1361
+ end
1362
+
1067
1363
  # Create evaluation cache for flag dependencies
1068
1364
  evaluation_cache = {}
1069
1365
 
@@ -1088,7 +1384,8 @@ module PostHog
1088
1384
  "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
1089
1385
  )
1090
1386
  # failover to `/flags/`
1091
- raise InconclusiveMatchError, 'Flag has unknown group type index'
1387
+ raise InconclusiveMatchError.new('Flag has unknown group type index',
1388
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
1092
1389
  end
1093
1390
 
1094
1391
  group_name_symbol = group_name.to_sym
@@ -1133,7 +1430,7 @@ module PostHog
1133
1430
  flag_conditions = flag_filters[:groups] || []
1134
1431
  flag_aggregation = flag_filters[:aggregation_group_type_index]
1135
1432
  early_exit = flag_filters[:early_exit] == true
1136
- is_inconclusive = false
1433
+ inconclusive_reason = nil
1137
1434
  result = nil
1138
1435
 
1139
1436
  # NOTE: This NEEDS to be `each` because `each_key` breaks
@@ -1161,7 +1458,9 @@ module PostHog
1161
1458
  next
1162
1459
  end
1163
1460
  unless group_properties.key?(group_name.to_sym)
1164
- is_inconclusive = true
1461
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(
1462
+ inconclusive_reason, UnresolvedFlagReason::MISSING_CONTEXT
1463
+ )
1165
1464
  next
1166
1465
  end
1167
1466
  effective_properties = group_properties[group_name.to_sym]
@@ -1185,25 +1484,38 @@ module PostHog
1185
1484
  when :out_of_rollout_bound
1186
1485
  break if early_exit
1187
1486
  end
1188
- rescue RequiresServerEvaluation
1189
- # Static cohort or other missing server-side data - must fallback to API
1190
- raise
1191
- rescue InconclusiveMatchError
1487
+ rescue RequiresServerEvaluation => e
1488
+ # Static cohort or other missing server-side data - must fallback to API. Evaluation stops here and
1489
+ # skips filters it did not reach, so the flag's definition still counts toward the reason.
1490
+ reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1491
+ definition_reason = _definition_unresolved_reason(flag, snapshot)
1492
+ reason = FeatureFlagsPoller.pick_unresolved_reason(reason, definition_reason) unless definition_reason.nil?
1493
+ raise RequiresServerEvaluation.new(e.message, reason: reason)
1494
+ rescue InconclusiveMatchError => e
1192
1495
  # Evaluation error (bad regex, invalid date, missing property, etc.)
1193
1496
  # Track that we had an inconclusive match, but try other conditions
1194
- is_inconclusive = true
1497
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1195
1498
  end
1196
1499
 
1197
1500
  if !result.nil?
1198
1501
  return result
1199
- elsif is_inconclusive
1200
- raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
1502
+ elsif !inconclusive_reason.nil?
1503
+ raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties",
1504
+ reason: inconclusive_reason)
1201
1505
  end
1202
1506
 
1203
1507
  # We can only return False when all conditions are False
1204
1508
  false
1205
1509
  end
1206
1510
 
1511
+ def _definition_unresolved_reason(flag, snapshot)
1512
+ self.class.definition_unresolved_reason(flag, snapshot[:flags_by_key] || {}, snapshot[:cohorts],
1513
+ snapshot[:group_type_mapping])
1514
+ rescue StandardError
1515
+ # A malformed definition must not stop the fallback to the server.
1516
+ nil
1517
+ end
1518
+
1207
1519
  def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
1208
1520
  snapshot: _evaluation_snapshot)
1209
1521
  condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
@@ -1222,16 +1534,44 @@ module PostHog
1222
1534
  rollout_percentage = condition[:rollout_percentage]
1223
1535
 
1224
1536
  unless (condition[:properties] || []).empty?
1225
- unless condition[:properties].all? do |prop|
1226
- if prop[:type] == 'flag'
1227
- evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
1228
- snapshot: snapshot)
1229
- else
1230
- FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
1231
- property_matching_version: snapshot[:property_matching_version])
1232
- end
1537
+ # Filters in a condition have no defined order, so an inconclusive filter does not stop the
1538
+ # loop: a later filter can still rule the condition out or carry a higher-precedence reason.
1539
+ inconclusive_reason = nil
1540
+ server_evaluation_error = nil
1541
+ condition[:properties].each do |prop|
1542
+ matches =
1543
+ if prop[:type] == 'flag'
1544
+ evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
1545
+ snapshot: snapshot)
1546
+ else
1547
+ FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
1548
+ property_matching_version: snapshot[:property_matching_version])
1549
+ end
1550
+ return :no_match unless matches
1551
+ rescue RequiresServerEvaluation => e
1552
+ # Fall back to the server only when this is the first filter that could not be checked, as
1553
+ # when the loop stopped there. After another inconclusive filter, later conditions must still
1554
+ # be able to resolve the flag locally.
1555
+ server_evaluation_error ||= e if inconclusive_reason.nil?
1556
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1557
+ rescue InconclusiveMatchError => e
1558
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1559
+ rescue StandardError
1560
+ raise if inconclusive_reason.nil?
1561
+
1562
+ # The condition is already inconclusive, so an unexpected error in a later filter must not
1563
+ # fail the whole flag. Later conditions can still resolve it.
1564
+ break
1233
1565
  end
1234
- return :no_match
1566
+
1567
+ # Raise the original error type: match_feature_flag_properties stops at it and falls back to the
1568
+ # server instead of trying later conditions. A later filter can still carry a higher-precedence reason.
1569
+ unless server_evaluation_error.nil?
1570
+ raise RequiresServerEvaluation.new(server_evaluation_error.message, reason: inconclusive_reason)
1571
+ end
1572
+ unless inconclusive_reason.nil?
1573
+ raise InconclusiveMatchError.new("Can't determine if a condition of flag #{flag[:key]} matches",
1574
+ reason: inconclusive_reason)
1235
1575
  end
1236
1576
 
1237
1577
  return :match if rollout_percentage.nil?
@@ -1432,6 +1772,8 @@ module PostHog
1432
1772
  end
1433
1773
  new_group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
1434
1774
  new_cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1775
+ unresolvable_flags = _unresolvable_flags(new_flags, new_by_key, new_cohorts, new_group_type_mapping)
1776
+ unwarned_flags = nil
1435
1777
 
1436
1778
  # A read of the new definitions must not record its event before the tracker reset.
1437
1779
  @flag_definitions_update_mutex.synchronize do
@@ -1448,11 +1790,57 @@ module PostHog
1448
1790
  @minimal_flag_called_events = minimal_flag_called_events == true
1449
1791
  @flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
1450
1792
  @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1793
+ # Rebuilt from the current definitions, so a flag that is deleted or becomes resolvable is
1794
+ # forgotten and warns again if it later returns to an unresolvable definition.
1795
+ unwarned_flags = unresolvable_flags.reject { |key, entry| @warned_unresolvable_flags[key] == entry }
1796
+ @warned_unresolvable_flags = unresolvable_flags
1451
1797
  @on_flag_definitions_updated&.call
1452
1798
  end
1799
+ _warn_about_unresolvable_flags(unwarned_flags)
1453
1800
  logger.debug "Loaded #{new_flags.length} feature flags and #{new_cohorts.length} cohorts"
1454
1801
  end
1455
1802
 
1803
+ # Flag key => [reason, definition] for each flag that local evaluation never resolves. Hash#== compares
1804
+ # the definition by content and ignores key order, so a reordered copy does not warn again.
1805
+ def _unresolvable_flags(flags, flags_by_key, cohorts, group_type_mapping)
1806
+ flags.each_with_object({}) do |flag, unresolvable|
1807
+ reason = self.class.definition_unresolved_reason(flag, flags_by_key, cohorts, group_type_mapping)
1808
+ next if reason.nil? || flag[:key].nil?
1809
+
1810
+ unresolvable[flag[:key]] = [reason, flag]
1811
+ rescue StandardError
1812
+ # Evaluation reports a definition this malformed when the flag is used.
1813
+ next
1814
+ end
1815
+ end
1816
+
1817
+ def _warn_about_unresolvable_flags(unresolvable_flags)
1818
+ keys_by_reason = unresolvable_flags.keys.group_by { |key| unresolvable_flags[key].first }
1819
+
1820
+ experience_continuity_keys = keys_by_reason[UnresolvedFlagReason::EXPERIENCE_CONTINUITY]
1821
+ if experience_continuity_keys
1822
+ logger.warn(
1823
+ "[FEATURE FLAGS] #{experience_continuity_keys.length} flag(s) have experience continuity enabled and " \
1824
+ "can't be evaluated locally (reason: #{UnresolvedFlagReason::EXPERIENCE_CONTINUITY}): " \
1825
+ "#{experience_continuity_keys.join(', ')}. Evaluating them makes a remote request, or leaves them " \
1826
+ 'unresolved with only_evaluate_locally: true. To evaluate them locally, disable experience continuity ' \
1827
+ 'on these flags in PostHog.'
1828
+ )
1829
+ end
1830
+
1831
+ unsupported_definition_keys = keys_by_reason[UnresolvedFlagReason::UNSUPPORTED_DEFINITION]
1832
+ return unless unsupported_definition_keys
1833
+
1834
+ logger.warn(
1835
+ "[FEATURE FLAGS] #{unsupported_definition_keys.length} flag(s) use something local evaluation doesn't " \
1836
+ 'support, such as a static cohort, an unknown operator, a malformed value, or a missing or circular flag ' \
1837
+ "dependency (reason: #{UnresolvedFlagReason::UNSUPPORTED_DEFINITION}): " \
1838
+ "#{unsupported_definition_keys.join(', ')}. Evaluating them makes a remote request when evaluation " \
1839
+ 'reaches that part, or leaves them unresolved with only_evaluate_locally: true. To evaluate them ' \
1840
+ 'locally, change these flags in PostHog or upgrade posthog-ruby.'
1841
+ )
1842
+ end
1843
+
1456
1844
  def _request_feature_flag_definitions(etag: nil)
1457
1845
  uri = URI("#{@host}/flags/definitions")
1458
1846
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])