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.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +61 -13
- data/lib/posthog/feature_flag_error.rb +2 -0
- data/lib/posthog/feature_flag_evaluations.rb +20 -2
- data/lib/posthog/feature_flags.rb +546 -148
- data/lib/posthog/flag_evaluation_errors.rb +35 -0
- data/lib/posthog/internal/context.rb +12 -6
- data/lib/posthog/internal/span_context.rb +34 -0
- data/lib/posthog/mcp/instrumentation.rb +3 -1
- data/lib/posthog/mcp/request_scope.rb +26 -15
- data/lib/posthog/otel/context_span_processor.rb +21 -0
- data/lib/posthog/unresolved_flag_reason.rb +20 -0
- data/lib/posthog/utils.rb +5 -6
- data/lib/posthog/version.rb +1 -1
- data/lib/posthog.rb +1 -0
- metadata +5 -1
|
@@ -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
|
-
|
|
552
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
778
|
-
|
|
779
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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
|
|
911
|
-
elsif
|
|
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 #{
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
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
|
-
#
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
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
|
|
1200
|
-
raise InconclusiveMatchError
|
|
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
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
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]])
|