posthog-ruby 3.28.4 → 3.29.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.
@@ -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,
@@ -485,6 +496,12 @@ module PostHog
485
496
 
486
497
  # Class methods
487
498
 
499
+ def self.pick_unresolved_reason(current, reason)
500
+ return reason if current.nil?
501
+
502
+ UNRESOLVED_REASON_PRECEDENCE.index(reason) < UNRESOLVED_REASON_PRECEDENCE.index(current) ? reason : current
503
+ end
504
+
488
505
  def self.compare(lhs, rhs, operator)
489
506
  case operator
490
507
  when 'gt'
@@ -547,33 +564,36 @@ module PostHog
547
564
  end
548
565
 
549
566
  # 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+$/
567
+ # leading-zero values per semver 2.0.0 §2. `reason` names the owner of the value:
568
+ # UNSUPPORTED_DEFINITION for a filter value, MISSING_CONTEXT for a value the call passed.
569
+ def self.parse_semver_numeric(part, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
570
+ if part.nil? || part.empty? || part !~ /^\d+$/
571
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason)
572
+ end
553
573
  # 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'
574
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if part.length > 1 && part[0] == '0'
555
575
 
556
576
  part.to_i
557
577
  end
558
578
 
559
579
  # Parse a semver string into a comparable [major, minor, patch] integer array.
560
580
  # 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?
581
+ def self.parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
582
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if value.nil?
563
583
 
564
584
  text = value.to_s.strip.sub(/^[vV]/, '')
565
585
 
566
- raise InconclusiveMatchError, 'Invalid semver format' if text.empty?
586
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if text.empty?
567
587
 
568
588
  # Strip pre-release and build metadata suffixes
569
589
  text = text.split('-')[0].split('+')[0]
570
590
  parts = text.split('.')
571
591
 
572
- raise InconclusiveMatchError, 'Invalid semver format' if parts.empty? || parts[0].to_s.empty?
592
+ raise InconclusiveMatchError.new('Invalid semver format', reason: reason) if parts.empty? || parts[0].to_s.empty?
573
593
 
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
594
+ major = parse_semver_numeric(parts[0], reason: reason)
595
+ minor = parts.length > 1 ? parse_semver_numeric(parts[1], reason: reason) : 0
596
+ patch = parts.length > 2 ? parse_semver_numeric(parts[2], reason: reason) : 0
577
597
 
578
598
  [major, minor, patch]
579
599
  end
@@ -628,12 +648,16 @@ module PostHog
628
648
  cleaned = value.to_s.strip.sub(/^[vV]/, '').gsub('*', '').chomp('.')
629
649
  parts = cleaned.split('.').reject(&:empty?)
630
650
 
631
- raise InconclusiveMatchError, 'Invalid semver wildcard format' if parts.empty?
651
+ if parts.empty?
652
+ raise InconclusiveMatchError.new('Invalid semver wildcard format',
653
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
654
+ end
632
655
 
633
656
  numeric = parts.map do |part|
634
657
  parse_semver_numeric(part)
635
658
  rescue InconclusiveMatchError
636
- raise InconclusiveMatchError, 'Invalid semver wildcard format'
659
+ raise InconclusiveMatchError.new('Invalid semver wildcard format',
660
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
637
661
  end
638
662
 
639
663
  major = numeric[0]
@@ -719,12 +743,23 @@ module PostHog
719
743
  property_matching_version: property_matching_version)
720
744
  end
721
745
 
746
+ begin
747
+ match_property_value(property, property_values, property_matching_version)
748
+ rescue InconclusiveMatchError => e
749
+ # An unusable filter outranks missing context, so check the filter before blaming the context.
750
+ assert_filter_supported(property) if e.reason == UnresolvedFlagReason::MISSING_CONTEXT
751
+ raise
752
+ end
753
+ end
754
+
755
+ def self.match_property_value(property, property_values, property_matching_version)
722
756
  key = property[:key].to_sym
723
757
  value = property[:value]
724
758
  operator = property[:operator] || 'exact'
725
759
 
726
760
  if !property_values.key?(key)
727
- raise InconclusiveMatchError, "Property #{key} not found in property_values"
761
+ raise InconclusiveMatchError.new("Property #{key} not found in property_values",
762
+ reason: UnresolvedFlagReason::MISSING_CONTEXT)
728
763
  elsif operator == 'is_not_set'
729
764
  return false
730
765
  end
@@ -749,34 +784,32 @@ module PostHog
749
784
  string_operand(override_value).downcase(:ascii).end_with?(value.to_s.downcase(:ascii))
750
785
  when 'not_ends_with'
751
786
  !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?
787
+ when *REGEX_OPERATORS
788
+ matched = match_regex(value.to_s, string_operand(override_value))
789
+ return false if matched.nil?
759
790
 
791
+ operator == 'regex' ? matched : !matched
792
+ when *NUMERIC_OPERATORS
793
+ parsed_value = parse_numeric_filter(value)
760
794
  parsed_override = parse_finite_number(override_value)
761
795
  return false if parsed_override.nil?
762
796
 
763
797
  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
798
+ when *DATE_OPERATORS
799
+ # Parses the filter before the context, so a bad filter reports unsupported_definition even
800
+ # when the context value is bad too.
801
+ parsed_date = parse_date_filter(value)
802
+ override_date = PostHog::Utils.convert_to_datetime(override_value.to_s,
803
+ reason: UnresolvedFlagReason::MISSING_CONTEXT)
771
804
 
772
805
  if operator == 'is_date_before'
773
806
  override_date < parsed_date
774
807
  elsif operator == 'is_date_after'
775
808
  override_date > parsed_date
776
809
  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)
810
+ when *SEMVER_COMPARISON_OPERATORS
811
+ flag_parsed = parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
812
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
780
813
 
781
814
  case operator
782
815
  when 'semver_eq'
@@ -793,22 +826,227 @@ module PostHog
793
826
  (override_parsed <=> flag_parsed) <= 0
794
827
  end
795
828
  when 'semver_tilde'
796
- override_parsed = parse_semver(override_value)
797
829
  lower, upper = semver_tilde_bounds(value)
830
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
798
831
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
799
832
  when 'semver_caret'
800
- override_parsed = parse_semver(override_value)
801
833
  lower, upper = semver_caret_bounds(value)
834
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
802
835
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
803
836
  when 'semver_wildcard'
804
- override_parsed = parse_semver(override_value)
805
837
  lower, upper = semver_wildcard_bounds(value)
838
+ override_parsed = parse_semver(override_value, reason: UnresolvedFlagReason::MISSING_CONTEXT)
806
839
  (override_parsed <=> lower) >= 0 && (override_parsed <=> upper) == -1
807
840
  else
808
- raise InconclusiveMatchError, "Unknown operator: #{operator}"
841
+ raise InconclusiveMatchError.new("Unknown operator: #{operator}",
842
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
843
+ end
844
+ end
845
+ private_class_method :match_property_value
846
+
847
+ # Raises an unsupported_definition error when local evaluation cannot use the filter, whatever
848
+ # the call passes: an unknown operator, or a filter value that the operator cannot parse.
849
+ def self.assert_filter_supported(property)
850
+ value = property[:value]
851
+ case property[:operator] || 'exact'
852
+ when 'exact', 'is_not', 'is_set', 'is_not_set', 'icontains', 'not_icontains', 'starts_with',
853
+ 'not_starts_with', 'ends_with', 'not_ends_with'
854
+ nil
855
+ when *REGEX_OPERATORS
856
+ # An empty input is the most portable one, so this raises only for a pattern that needs the
857
+ # server whatever the value. Compile without matching, so a server-supplied pattern never runs
858
+ # while definitions load.
859
+ pattern = value.to_s
860
+ compile_regex(pattern, '') unless pattern.empty?
861
+ when *NUMERIC_OPERATORS
862
+ parse_numeric_filter(value)
863
+ when *DATE_OPERATORS
864
+ parse_date_filter(value)
865
+ when *SEMVER_COMPARISON_OPERATORS
866
+ parse_semver(value, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
867
+ when 'semver_tilde'
868
+ semver_tilde_bounds(value)
869
+ when 'semver_caret'
870
+ semver_caret_bounds(value)
871
+ when 'semver_wildcard'
872
+ semver_wildcard_bounds(value)
873
+ else
874
+ raise InconclusiveMatchError.new("Unknown operator: #{property[:operator]}",
875
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
809
876
  end
810
877
  end
811
878
 
879
+ def self.parse_numeric_filter(value)
880
+ parsed_value = parse_finite_number(value)
881
+ if parsed_value.nil?
882
+ raise InconclusiveMatchError.new('Invalid numeric condition',
883
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
884
+ end
885
+
886
+ parsed_value
887
+ end
888
+ private_class_method :parse_numeric_filter
889
+
890
+ def self.parse_date_filter(value)
891
+ parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s) ||
892
+ PostHog::Utils.convert_to_datetime(value.to_s,
893
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
894
+ return parsed_date if parsed_date
895
+
896
+ raise InconclusiveMatchError.new('Invalid date format', reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
897
+ end
898
+ private_class_method :parse_date_filter
899
+
900
+ def self.match_regex(pattern, value)
901
+ return true if pattern.empty?
902
+
903
+ regex = compile_regex(pattern, value)
904
+ return nil if regex.nil?
905
+
906
+ !regex.match(value).nil?
907
+ rescue InconclusiveMatchError
908
+ raise
909
+ rescue StandardError
910
+ raise InconclusiveMatchError.new('Regex execution requires server evaluation',
911
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
912
+ end
913
+ private_class_method :match_regex
914
+
915
+ # Returns nil for a pattern that Ruby cannot compile.
916
+ def self.compile_regex(pattern, value)
917
+ unless portable_regex?(pattern, value)
918
+ raise InconclusiveMatchError.new('Regex compatibility requires server evaluation',
919
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
920
+ end
921
+
922
+ Regexp.new(pattern)
923
+ rescue RegexpError => e
924
+ # Ruby's repeat-count limit is smaller than the service's syntax limit.
925
+ if e.message.include?('too big')
926
+ raise InconclusiveMatchError.new('Regex compilation requires server evaluation',
927
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
928
+ end
929
+
930
+ nil
931
+ rescue InconclusiveMatchError
932
+ raise
933
+ rescue StandardError
934
+ # For example, a pattern with an invalid byte sequence.
935
+ raise InconclusiveMatchError.new('Regex compatibility requires server evaluation',
936
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
937
+ end
938
+ private_class_method :compile_regex
939
+
940
+ # Native compilation alone cannot establish compatibility with the flags service's Rust engine.
941
+ # Keep ordinary scalar matching local, but defer engine-specific syntax and backtracking assertions.
942
+ def self.portable_regex?(pattern, value)
943
+ # Ruby string offsets repeatedly traverse multibyte prefixes; tokenize once for constant-time lookahead.
944
+ characters = pattern.chars
945
+ ascii_input = value.ascii_only?
946
+ newline_input = value.include?("\n")
947
+ class_start = nil
948
+ previous_token = nil
949
+ index = 0
950
+ while index < characters.length
951
+ character = characters[index]
952
+ if character == '\\'
953
+ index += 1
954
+ escaped = characters[index]
955
+ break if escaped.nil?
956
+
957
+ if escaped == 'x'
958
+ hex = characters[index + 1, 2]&.join
959
+ return false unless hex&.match?(/\A[0-9a-fA-F]{2}\z/) && hex.to_i(16) < 128
960
+
961
+ index += 2
962
+ elsif 'dDsSwW'.include?(escaped)
963
+ # Ruby shorthand classes are ASCII-oriented, unlike the service's Unicode classes.
964
+ return false unless ascii_input
965
+ elsif class_start.nil?
966
+ return false if 'bB'.include?(escaped) # Even short ASCII inputs can exceed the service's boundary budget.
967
+ return false unless 'Azfnrtv'.include?(escaped) || escaped.match?(/[\x20-\x2f:;=?@\x5b-\x60\x7b-\x7e]/)
968
+ else
969
+ return false unless 'bfnrtv\\.^$|?*+(){}[]-'.include?(escaped)
970
+ end
971
+ previous_token = class_start.nil? && %w[A z].include?(escaped) ? :assertion : :literal
972
+ elsif character == '['
973
+ return false unless class_start.nil?
974
+
975
+ class_start = index
976
+ elsif character == ']' && class_start
977
+ content_start = class_start + (characters[class_start + 1] == '^' ? 2 : 1)
978
+ return false if index == content_start
979
+
980
+ class_start = nil
981
+ previous_token = :literal
982
+ elsif class_start
983
+ return false if %w[&& -- ~~].include?(characters[index, 2].join)
984
+ else
985
+ token = character
986
+ if character == '(' && characters[index + 1] == '?'
987
+ return false unless characters[index + 2] == ':'
988
+
989
+ index += 2
990
+ elsif character == '{'
991
+ repetition_end = regex_repetition_end(characters, index)
992
+ return false unless repetition_end && ![nil, '|', '('].include?(previous_token)
993
+ return false if %i[assertion empty_group repetition lazy_repetition
994
+ bounded_repetition].include?(previous_token)
995
+
996
+ index = repetition_end
997
+ token = :bounded_repetition
998
+ elsif '*+?'.include?(character)
999
+ # A leading quantifier is invalid in both engines, regardless of the remaining syntax.
1000
+ return true if [nil, '|', '('].include?(previous_token)
1001
+ return false if %i[assertion empty_group].include?(previous_token)
1002
+
1003
+ repeated = %i[repetition lazy_repetition bounded_repetition].include?(previous_token)
1004
+ return false if repeated && !(character == '?' && previous_token == :repetition)
1005
+
1006
+ token = :repetition
1007
+ token = :lazy_repetition if repeated
1008
+ elsif '^$'.include?(character)
1009
+ # Ruby ^/$ are line anchors even without a multiline option; Rust's defaults are absolute.
1010
+ return false if newline_input
1011
+
1012
+ token = :assertion
1013
+ elsif character == ')' && ['(', :empty_group].include?(previous_token)
1014
+ token = :empty_group
1015
+ end
1016
+ previous_token = token
1017
+ end
1018
+ index += 1
1019
+ end
1020
+ true
1021
+ end
1022
+ private_class_method :portable_regex?
1023
+
1024
+ def self.regex_repetition_end(characters, index)
1025
+ index += 1
1026
+ return nil unless characters[index]&.match?(/\A[0-9]\z/)
1027
+
1028
+ count = 0
1029
+ comma = false
1030
+ while index < characters.length
1031
+ character = characters[index]
1032
+ digit = character.ord - 48
1033
+ if digit.between?(0, 9)
1034
+ count = (count * 10) + digit
1035
+ return nil if count > 0xffffffff
1036
+ elsif character == ',' && !comma
1037
+ comma = true
1038
+ count = 0
1039
+ elsif character == '}'
1040
+ return index
1041
+ else
1042
+ return nil
1043
+ end
1044
+ index += 1
1045
+ end
1046
+ nil
1047
+ end
1048
+ private_class_method :regex_repetition_end
1049
+
812
1050
  def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
813
1051
  # Cohort properties are in the form of property groups like this:
814
1052
  # {
@@ -823,8 +1061,10 @@ module PostHog
823
1061
  property_group = find_cohort_property(cohort_properties, cohort_id)
824
1062
 
825
1063
  unless property_group
826
- raise RequiresServerEvaluation,
827
- "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
1064
+ raise RequiresServerEvaluation.new(
1065
+ "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation",
1066
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1067
+ )
828
1068
  end
829
1069
 
830
1070
  match_property_group(property_group, property_values, cohort_properties,
@@ -840,11 +1080,17 @@ module PostHog
840
1080
  return true if properties.nil? || properties.empty?
841
1081
 
842
1082
  if nested_property_group?(properties)
843
- match_nested_property_group(properties, group_type, property_values, cohort_properties,
844
- property_matching_version: property_matching_version)
1083
+ property_group_items_match?(properties, group_type) do |group|
1084
+ match_property_group(group, property_values, cohort_properties,
1085
+ property_matching_version: property_matching_version)
1086
+ end
845
1087
  else
846
- match_regular_property_group(properties, group_type, property_values, cohort_properties,
847
- property_matching_version: property_matching_version)
1088
+ property_group_items_match?(properties, group_type) do |prop|
1089
+ PostHog::Utils.symbolize_keys!(prop)
1090
+ matches = match_property(prop, property_values, cohort_properties,
1091
+ property_matching_version: property_matching_version)
1092
+ prop[:negation] ? !matches : matches
1093
+ end
848
1094
  end
849
1095
  end
850
1096
 
@@ -869,59 +1115,32 @@ module PostHog
869
1115
  first_property.key?(:values) || first_property.key?('values')
870
1116
  end
871
1117
 
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}"
1118
+ # Combines the items of a cohort property group with AND or OR. The block evaluates one item.
1119
+ # RequiresServerEvaluation is not an InconclusiveMatchError, so it propagates at once.
1120
+ def self.property_group_items_match?(items, group_type)
1121
+ unless %w[AND OR].include?(group_type)
1122
+ raise InconclusiveMatchError.new("Unknown property group type: #{group_type}",
1123
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
889
1124
  end
890
- end
891
1125
 
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
-
905
- negated = prop[:negation] || false
906
- final_result = negated ? !matches : matches
907
-
908
- # Short-circuit based on group type
1126
+ inconclusive_reason = nil
1127
+ items.each do |item|
1128
+ matches = yield(item)
909
1129
  if group_type == 'AND'
910
- return false unless final_result
911
- elsif final_result # group_type == 'OR'
1130
+ return false unless matches
1131
+ elsif matches
912
1132
  return true
913
1133
  end
914
- rescue RequiresServerEvaluation
915
- # Immediately propagate - this condition requires server-side data
916
- raise
917
1134
  rescue InconclusiveMatchError => e
918
- PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
919
- error_matching_locally = true
1135
+ PostHog::Logging.logger&.debug("Failed to compute property #{item} locally: #{e}")
1136
+ inconclusive_reason = pick_unresolved_reason(inconclusive_reason, e.reason)
920
1137
  end
921
1138
 
922
- raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
1139
+ unless inconclusive_reason.nil?
1140
+ raise InconclusiveMatchError.new("can't match cohort without a given cohort property value",
1141
+ reason: inconclusive_reason)
1142
+ end
923
1143
 
924
- # If we reach here, return default based on group type
925
1144
  group_type == 'AND'
926
1145
  end
927
1146
 
@@ -935,48 +1154,38 @@ module PostHog
935
1154
  # @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
936
1155
  def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties,
937
1156
  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
1157
  if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
944
1158
  # 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'
1159
+ raise InconclusiveMatchError.new(
1160
+ "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
1161
+ 'without feature flags loaded or evaluation_cache',
1162
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1163
+ )
948
1164
  end
949
1165
 
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"
1166
+ # A dependency with no definition outranks one that is only inconclusive, so this checks the whole
1167
+ # chain before evaluating any of it. Nothing is cached for it, so a later lookup reports the same.
1168
+ definition_error = FeatureFlagsPoller.flag_dependency_definition_error(property, snapshot[:flags_by_key])
1169
+ unless definition_error.nil?
1170
+ raise InconclusiveMatchError.new(definition_error, reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
956
1171
  end
957
1172
 
958
1173
  dependency_chain = property[:dependency_chain]
959
1174
 
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
1175
  # Evaluate all dependencies in the chain order
968
1176
  dependency_chain.each do |dep_flag_key|
969
1177
  unless evaluation_cache.key?(dep_flag_key)
970
1178
  # Need to evaluate this dependency first
971
1179
  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]
1180
+ if !dep_flag[:active]
978
1181
  # Check if the flag is active (same check as in _compute_flag_locally)
979
1182
  evaluation_cache[dep_flag_key] = false
1183
+ elsif dep_flag[:ensure_experience_continuity]
1184
+ evaluation_cache[dep_flag_key] = nil
1185
+ raise InconclusiveMatchError.new(
1186
+ "Cannot evaluate flag dependency '#{dep_flag_key}' - flag has experience continuity enabled",
1187
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY
1188
+ )
980
1189
  else
981
1190
  # Recursively evaluate the dependency using existing instance method
982
1191
  begin
@@ -989,11 +1198,16 @@ module PostHog
989
1198
  snapshot: snapshot
990
1199
  )
991
1200
  evaluation_cache[dep_flag_key] = dep_result
1201
+ rescue RequiresServerEvaluation => e
1202
+ # The dependent flag needs the server too. Nothing is cached, so a later lookup of this
1203
+ # dependency also requires the server instead of reading it as merely inconclusive.
1204
+ raise RequiresServerEvaluation.new("Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}",
1205
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
992
1206
  rescue InconclusiveMatchError => e
993
1207
  # If we can't evaluate a dependency, store nil and propagate the error
994
1208
  evaluation_cache[dep_flag_key] = nil
995
- raise InconclusiveMatchError,
996
- "Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
1209
+ raise InconclusiveMatchError.new("Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}",
1210
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
997
1211
  end
998
1212
  end
999
1213
  end
@@ -1002,8 +1216,8 @@ module PostHog
1002
1216
  cached_result = evaluation_cache[dep_flag_key]
1003
1217
  if cached_result.nil?
1004
1218
  # Previously inconclusive - raise error again
1005
- raise InconclusiveMatchError,
1006
- "Flag dependency '#{dep_flag_key}' was previously inconclusive"
1219
+ raise InconclusiveMatchError.new("Flag dependency '#{dep_flag_key}' was previously inconclusive",
1220
+ reason: UnresolvedFlagReason::UNRESOLVED_DEPENDENCY)
1007
1221
  elsif !cached_result
1008
1222
  # Definitive False result - dependency failed
1009
1223
  return false
@@ -1049,8 +1263,96 @@ module PostHog
1049
1263
  false
1050
1264
  end
1051
1265
 
1266
+ # The unresolved reason that comes from the flag's definition alone, so it is known when
1267
+ # definitions load. Matches the reason local evaluation reports for the same cause.
1268
+ def self.definition_unresolved_reason(flag, flags_by_key, cohorts, group_type_mapping)
1269
+ return nil unless flag[:active]
1270
+ return UnresolvedFlagReason::EXPERIENCE_CONTINUITY if flag[:ensure_experience_continuity]
1271
+
1272
+ flag_filters = flag[:filters] || {}
1273
+ aggregation = flag_filters[:aggregation_group_type_index]
1274
+ if !aggregation.nil? && group_type_mapping[aggregation.to_s.to_sym].nil?
1275
+ return UnresolvedFlagReason::UNSUPPORTED_DEFINITION
1276
+ end
1277
+
1278
+ unsupported = (flag_filters[:groups] || []).any? do |condition|
1279
+ (condition[:properties] || []).any? do |property|
1280
+ unsupported_property?(property, flags_by_key, cohorts, Set.new)
1281
+ end
1282
+ end
1283
+ unsupported ? UnresolvedFlagReason::UNSUPPORTED_DEFINITION : nil
1284
+ end
1285
+
1286
+ # Why a flag dependency filter can never evaluate locally, whatever the call passes, or nil.
1287
+ # Evaluation and the load-time warning both use it, so the warning matches the reason evaluation reports.
1288
+ def self.flag_dependency_definition_error(property, flags_by_key)
1289
+ operator = property[:operator]
1290
+ return "Operator #{operator} not supported for flag dependencies" if operator != 'flag_evaluates_to'
1291
+
1292
+ key = property[:key] || 'unknown'
1293
+ dependency_chain = property[:dependency_chain]
1294
+ unless property.key?(:dependency_chain)
1295
+ return "Flag dependency property for '#{key}' is missing required 'dependency_chain' field"
1296
+ end
1297
+ unless dependency_chain.is_a?(Array)
1298
+ return "Flag dependency property for '#{key}' has an invalid 'dependency_chain' field"
1299
+ end
1300
+ return "Circular dependency detected for flag '#{key}'" if dependency_chain.empty?
1301
+
1302
+ missing_dependency = dependency_chain.find { |dep_flag_key| flags_by_key[dep_flag_key].nil? }
1303
+ return nil if missing_dependency.nil?
1304
+
1305
+ "Cannot evaluate flag dependency '#{missing_dependency}' - flag not found in local flags"
1306
+ end
1307
+
1308
+ def self.unsupported_property?(property, flags_by_key, cohorts, visited_cohort_ids)
1309
+ case property[:type]
1310
+ when 'flag'
1311
+ !flag_dependency_definition_error(property, flags_by_key).nil?
1312
+ when 'cohort'
1313
+ cohort_id = property[:value].to_s
1314
+ property_group = find_cohort_property(cohorts, cohort_id)
1315
+ return true if property_group.nil?
1316
+ return false unless visited_cohort_ids.add?(cohort_id)
1317
+
1318
+ unsupported_property_group?(property_group, flags_by_key, cohorts, visited_cohort_ids)
1319
+ else
1320
+ unsupported_filter?(property)
1321
+ end
1322
+ end
1323
+
1324
+ def self.unsupported_property_group?(property_group, flags_by_key, cohorts, visited_cohort_ids)
1325
+ properties = extract_value(property_group, :values)
1326
+ return false if properties.nil? || properties.empty?
1327
+ return true unless %w[AND OR].include?(extract_value(property_group, :type))
1328
+
1329
+ if nested_property_group?(properties)
1330
+ return properties.any? do |group|
1331
+ unsupported_property_group?(group, flags_by_key, cohorts, visited_cohort_ids)
1332
+ end
1333
+ end
1334
+
1335
+ properties.any? do |property|
1336
+ filter = PostHog::Utils.symbolize_keys(property)
1337
+ if filter[:type] == 'cohort'
1338
+ unsupported_property?(filter, flags_by_key, cohorts, visited_cohort_ids)
1339
+ else
1340
+ # Like evaluation, a cohort group checks a flag filter as an ordinary property.
1341
+ unsupported_filter?(filter)
1342
+ end
1343
+ end
1344
+ end
1345
+
1346
+ def self.unsupported_filter?(property)
1347
+ assert_filter_supported(property)
1348
+ false
1349
+ rescue InconclusiveMatchError
1350
+ true
1351
+ end
1352
+
1052
1353
  private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
1053
- :match_nested_property_group, :match_regular_property_group
1354
+ :property_group_items_match?, :assert_filter_supported,
1355
+ :unsupported_property?, :unsupported_property_group?, :unsupported_filter?
1054
1356
 
1055
1357
  # Publish/capture all matching state together so a poll cannot switch semantics
1056
1358
  # (or dependency/cohort definitions) halfway through one local evaluation.
@@ -1060,10 +1362,14 @@ module PostHog
1060
1362
 
1061
1363
  def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {},
1062
1364
  snapshot: _evaluation_snapshot)
1063
- raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
1064
-
1365
+ # An inactive flag is false whatever its experience continuity setting.
1065
1366
  return false unless flag[:active]
1066
1367
 
1368
+ if flag[:ensure_experience_continuity]
1369
+ raise RequiresServerEvaluation.new('Flag has experience continuity enabled',
1370
+ reason: UnresolvedFlagReason::EXPERIENCE_CONTINUITY)
1371
+ end
1372
+
1067
1373
  # Create evaluation cache for flag dependencies
1068
1374
  evaluation_cache = {}
1069
1375
 
@@ -1088,7 +1394,8 @@ module PostHog
1088
1394
  "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
1089
1395
  )
1090
1396
  # failover to `/flags/`
1091
- raise InconclusiveMatchError, 'Flag has unknown group type index'
1397
+ raise InconclusiveMatchError.new('Flag has unknown group type index',
1398
+ reason: UnresolvedFlagReason::UNSUPPORTED_DEFINITION)
1092
1399
  end
1093
1400
 
1094
1401
  group_name_symbol = group_name.to_sym
@@ -1133,7 +1440,7 @@ module PostHog
1133
1440
  flag_conditions = flag_filters[:groups] || []
1134
1441
  flag_aggregation = flag_filters[:aggregation_group_type_index]
1135
1442
  early_exit = flag_filters[:early_exit] == true
1136
- is_inconclusive = false
1443
+ inconclusive_reason = nil
1137
1444
  result = nil
1138
1445
 
1139
1446
  # NOTE: This NEEDS to be `each` because `each_key` breaks
@@ -1161,7 +1468,9 @@ module PostHog
1161
1468
  next
1162
1469
  end
1163
1470
  unless group_properties.key?(group_name.to_sym)
1164
- is_inconclusive = true
1471
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(
1472
+ inconclusive_reason, UnresolvedFlagReason::MISSING_CONTEXT
1473
+ )
1165
1474
  next
1166
1475
  end
1167
1476
  effective_properties = group_properties[group_name.to_sym]
@@ -1185,25 +1494,38 @@ module PostHog
1185
1494
  when :out_of_rollout_bound
1186
1495
  break if early_exit
1187
1496
  end
1188
- rescue RequiresServerEvaluation
1189
- # Static cohort or other missing server-side data - must fallback to API
1190
- raise
1191
- rescue InconclusiveMatchError
1497
+ rescue RequiresServerEvaluation => e
1498
+ # Static cohort or other missing server-side data - must fallback to API. Evaluation stops here and
1499
+ # skips filters it did not reach, so the flag's definition still counts toward the reason.
1500
+ reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1501
+ definition_reason = _definition_unresolved_reason(flag, snapshot)
1502
+ reason = FeatureFlagsPoller.pick_unresolved_reason(reason, definition_reason) unless definition_reason.nil?
1503
+ raise RequiresServerEvaluation.new(e.message, reason: reason)
1504
+ rescue InconclusiveMatchError => e
1192
1505
  # Evaluation error (bad regex, invalid date, missing property, etc.)
1193
1506
  # Track that we had an inconclusive match, but try other conditions
1194
- is_inconclusive = true
1507
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1195
1508
  end
1196
1509
 
1197
1510
  if !result.nil?
1198
1511
  return result
1199
- elsif is_inconclusive
1200
- raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
1512
+ elsif !inconclusive_reason.nil?
1513
+ raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties",
1514
+ reason: inconclusive_reason)
1201
1515
  end
1202
1516
 
1203
1517
  # We can only return False when all conditions are False
1204
1518
  false
1205
1519
  end
1206
1520
 
1521
+ def _definition_unresolved_reason(flag, snapshot)
1522
+ self.class.definition_unresolved_reason(flag, snapshot[:flags_by_key] || {}, snapshot[:cohorts],
1523
+ snapshot[:group_type_mapping])
1524
+ rescue StandardError
1525
+ # A malformed definition must not stop the fallback to the server.
1526
+ nil
1527
+ end
1528
+
1207
1529
  def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
1208
1530
  snapshot: _evaluation_snapshot)
1209
1531
  condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
@@ -1222,16 +1544,44 @@ module PostHog
1222
1544
  rollout_percentage = condition[:rollout_percentage]
1223
1545
 
1224
1546
  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
1547
+ # Filters in a condition have no defined order, so an inconclusive filter does not stop the
1548
+ # loop: a later filter can still rule the condition out or carry a higher-precedence reason.
1549
+ inconclusive_reason = nil
1550
+ server_evaluation_error = nil
1551
+ condition[:properties].each do |prop|
1552
+ matches =
1553
+ if prop[:type] == 'flag'
1554
+ evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
1555
+ snapshot: snapshot)
1556
+ else
1557
+ FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
1558
+ property_matching_version: snapshot[:property_matching_version])
1559
+ end
1560
+ return :no_match unless matches
1561
+ rescue RequiresServerEvaluation => e
1562
+ # Fall back to the server only when this is the first filter that could not be checked, as
1563
+ # when the loop stopped there. After another inconclusive filter, later conditions must still
1564
+ # be able to resolve the flag locally.
1565
+ server_evaluation_error ||= e if inconclusive_reason.nil?
1566
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1567
+ rescue InconclusiveMatchError => e
1568
+ inconclusive_reason = FeatureFlagsPoller.pick_unresolved_reason(inconclusive_reason, e.reason)
1569
+ rescue StandardError
1570
+ raise if inconclusive_reason.nil?
1571
+
1572
+ # The condition is already inconclusive, so an unexpected error in a later filter must not
1573
+ # fail the whole flag. Later conditions can still resolve it.
1574
+ break
1575
+ end
1576
+
1577
+ # Raise the original error type: match_feature_flag_properties stops at it and falls back to the
1578
+ # server instead of trying later conditions. A later filter can still carry a higher-precedence reason.
1579
+ unless server_evaluation_error.nil?
1580
+ raise RequiresServerEvaluation.new(server_evaluation_error.message, reason: inconclusive_reason)
1233
1581
  end
1234
- return :no_match
1582
+ unless inconclusive_reason.nil?
1583
+ raise InconclusiveMatchError.new("Can't determine if a condition of flag #{flag[:key]} matches",
1584
+ reason: inconclusive_reason)
1235
1585
  end
1236
1586
 
1237
1587
  return :match if rollout_percentage.nil?
@@ -1432,6 +1782,8 @@ module PostHog
1432
1782
  end
1433
1783
  new_group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
1434
1784
  new_cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1785
+ unresolvable_flags = _unresolvable_flags(new_flags, new_by_key, new_cohorts, new_group_type_mapping)
1786
+ unwarned_flags = nil
1435
1787
 
1436
1788
  # A read of the new definitions must not record its event before the tracker reset.
1437
1789
  @flag_definitions_update_mutex.synchronize do
@@ -1448,11 +1800,57 @@ module PostHog
1448
1800
  @minimal_flag_called_events = minimal_flag_called_events == true
1449
1801
  @flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
1450
1802
  @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
1803
+ # Rebuilt from the current definitions, so a flag that is deleted or becomes resolvable is
1804
+ # forgotten and warns again if it later returns to an unresolvable definition.
1805
+ unwarned_flags = unresolvable_flags.reject { |key, entry| @warned_unresolvable_flags[key] == entry }
1806
+ @warned_unresolvable_flags = unresolvable_flags
1451
1807
  @on_flag_definitions_updated&.call
1452
1808
  end
1809
+ _warn_about_unresolvable_flags(unwarned_flags)
1453
1810
  logger.debug "Loaded #{new_flags.length} feature flags and #{new_cohorts.length} cohorts"
1454
1811
  end
1455
1812
 
1813
+ # Flag key => [reason, definition] for each flag that local evaluation never resolves. Hash#== compares
1814
+ # the definition by content and ignores key order, so a reordered copy does not warn again.
1815
+ def _unresolvable_flags(flags, flags_by_key, cohorts, group_type_mapping)
1816
+ flags.each_with_object({}) do |flag, unresolvable|
1817
+ reason = self.class.definition_unresolved_reason(flag, flags_by_key, cohorts, group_type_mapping)
1818
+ next if reason.nil? || flag[:key].nil?
1819
+
1820
+ unresolvable[flag[:key]] = [reason, flag]
1821
+ rescue StandardError
1822
+ # Evaluation reports a definition this malformed when the flag is used.
1823
+ next
1824
+ end
1825
+ end
1826
+
1827
+ def _warn_about_unresolvable_flags(unresolvable_flags)
1828
+ keys_by_reason = unresolvable_flags.keys.group_by { |key| unresolvable_flags[key].first }
1829
+
1830
+ experience_continuity_keys = keys_by_reason[UnresolvedFlagReason::EXPERIENCE_CONTINUITY]
1831
+ if experience_continuity_keys
1832
+ logger.warn(
1833
+ "[FEATURE FLAGS] #{experience_continuity_keys.length} flag(s) have experience continuity enabled and " \
1834
+ "can't be evaluated locally (reason: #{UnresolvedFlagReason::EXPERIENCE_CONTINUITY}): " \
1835
+ "#{experience_continuity_keys.join(', ')}. Evaluating them makes a remote request, or leaves them " \
1836
+ 'unresolved with only_evaluate_locally: true. To evaluate them locally, disable experience continuity ' \
1837
+ 'on these flags in PostHog.'
1838
+ )
1839
+ end
1840
+
1841
+ unsupported_definition_keys = keys_by_reason[UnresolvedFlagReason::UNSUPPORTED_DEFINITION]
1842
+ return unless unsupported_definition_keys
1843
+
1844
+ logger.warn(
1845
+ "[FEATURE FLAGS] #{unsupported_definition_keys.length} flag(s) use something local evaluation doesn't " \
1846
+ 'support, such as a static cohort, an unknown operator, a malformed value, or a missing or circular flag ' \
1847
+ "dependency (reason: #{UnresolvedFlagReason::UNSUPPORTED_DEFINITION}): " \
1848
+ "#{unsupported_definition_keys.join(', ')}. Evaluating them makes a remote request when evaluation " \
1849
+ 'reaches that part, or leaves them unresolved with only_evaluate_locally: true. To evaluate them ' \
1850
+ 'locally, change these flags in PostHog or upgrade posthog-ruby.'
1851
+ )
1852
+ end
1853
+
1456
1854
  def _request_feature_flag_definitions(etag: nil)
1457
1855
  uri = URI("#{@host}/flags/definitions")
1458
1856
  uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])