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