posthog-ruby 3.23.6 → 3.23.8
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 +14 -10
- data/lib/posthog/feature_flags.rb +192 -81
- data/lib/posthog/flag_definition_cache.rb +9 -5
- data/lib/posthog/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4074fb22041c597b36316d8f5ecd613adeb94c6e5440af887dbb0e4ccf95eb1
|
|
4
|
+
data.tar.gz: b0a587ad8bef2e363321de12c0fdb7cce636fc80338caa3e4009855d4665e9ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2aa7c9f54b15c7f4a53f3ed54419ac052a4ecbf9ab4b6454efe4fa4a99389e40ce35cf218aa19f5030e4cfb6ef3dcdb6d4669404687e050029d8ef77fb40a035
|
|
7
|
+
data.tar.gz: e3438baa285a1fab688cf3f7aa3192f87c5719c1dc086a43147d6f1e88609ec9281479a80984726c59a0ae26a1a1f2faf069e3428f9f7a6f9b6d89efb0e1c31b
|
data/lib/posthog/client.rb
CHANGED
|
@@ -191,6 +191,12 @@ module PostHog
|
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
# Initialize tracking before the poller can load definitions (including async loads).
|
|
195
|
+
@distinct_id_has_sent_flag_calls_mutex = Mutex.new
|
|
196
|
+
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
|
|
197
|
+
hash[key] = SizeLimitedArray.new(Defaults::MAX_HASH_SIZE)
|
|
198
|
+
end
|
|
199
|
+
|
|
194
200
|
unless @disabled
|
|
195
201
|
@feature_flags_poller =
|
|
196
202
|
FeatureFlagsPoller.new(
|
|
@@ -203,15 +209,12 @@ module PostHog
|
|
|
203
209
|
flag_definition_cache_provider: opts[:flag_definition_cache_provider],
|
|
204
210
|
feature_flag_request_max_retries: opts[:feature_flag_request_max_retries],
|
|
205
211
|
async_load: opts[:feature_flags_async_load] == true,
|
|
206
|
-
user_agent: @headers['User-Agent']
|
|
212
|
+
user_agent: @headers['User-Agent'],
|
|
213
|
+
flag_definitions_update_mutex: @distinct_id_has_sent_flag_calls_mutex,
|
|
214
|
+
on_flag_definitions_updated: -> { @distinct_id_has_sent_flag_calls.clear }
|
|
207
215
|
)
|
|
208
216
|
end
|
|
209
217
|
|
|
210
|
-
@distinct_id_has_sent_flag_calls_mutex = Mutex.new
|
|
211
|
-
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) do |hash, key|
|
|
212
|
-
hash[key] = SizeLimitedArray.new(Defaults::MAX_HASH_SIZE)
|
|
213
|
-
end
|
|
214
|
-
|
|
215
218
|
@before_send = opts[:before_send]
|
|
216
219
|
@is_server = opts.fetch(:is_server, true) != false
|
|
217
220
|
@deprecation_emitted_for = Concurrent::Set.new
|
|
@@ -656,7 +659,8 @@ module PostHog
|
|
|
656
659
|
flag_keys_set = flag_keys&.to_set(&:to_s)
|
|
657
660
|
|
|
658
661
|
@feature_flags_poller.load_feature_flags
|
|
659
|
-
|
|
662
|
+
definition_snapshot = @feature_flags_poller._evaluation_snapshot
|
|
663
|
+
poller_flags_by_key = definition_snapshot[:flags_by_key] || {}
|
|
660
664
|
|
|
661
665
|
poller_flags_by_key.each do |key, definition|
|
|
662
666
|
next if flag_keys_set && !flag_keys_set.include?(key.to_s)
|
|
@@ -664,7 +668,7 @@ module PostHog
|
|
|
664
668
|
begin
|
|
665
669
|
match = @feature_flags_poller.send(
|
|
666
670
|
:_compute_flag_locally,
|
|
667
|
-
definition, distinct_id, groups, person_properties, group_properties
|
|
671
|
+
definition, distinct_id, groups, person_properties, group_properties, snapshot: definition_snapshot
|
|
668
672
|
)
|
|
669
673
|
rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError, StandardError
|
|
670
674
|
next
|
|
@@ -677,7 +681,7 @@ module PostHog
|
|
|
677
681
|
enabled: match.is_a?(String) || (match ? true : false),
|
|
678
682
|
variant: match.is_a?(String) ? match : nil,
|
|
679
683
|
payload: FeatureFlagResult.parse_payload(
|
|
680
|
-
@feature_flags_poller.send(:_compute_flag_payload_locally, key, match)
|
|
684
|
+
@feature_flags_poller.send(:_compute_flag_payload_locally, key, match, snapshot: definition_snapshot)
|
|
681
685
|
),
|
|
682
686
|
id: definition[:id],
|
|
683
687
|
version: nil,
|
|
@@ -696,7 +700,7 @@ module PostHog
|
|
|
696
700
|
# the snapshot uses a remote /flags response, the response's top-level
|
|
697
701
|
# `minimalFlagCalledEvents` field governs; a local-only snapshot reads
|
|
698
702
|
# the gate polled with the flag definitions.
|
|
699
|
-
minimal_flag_called_events =
|
|
703
|
+
minimal_flag_called_events = definition_snapshot[:minimal_flag_called_events]
|
|
700
704
|
|
|
701
705
|
# Skip the remote `/flags` round-trip when the caller scoped the request
|
|
702
706
|
# to a fixed set of `flag_keys` and we've already resolved every one of
|
|
@@ -45,6 +45,9 @@ module PostHog
|
|
|
45
45
|
# immediate first tick at construction, then the regular polling cadence, which keeps retrying until a
|
|
46
46
|
# load succeeds.
|
|
47
47
|
# @param user_agent [String] User-Agent header sent with feature flag requests.
|
|
48
|
+
# @param flag_definitions_update_mutex [Mutex] Internal lock shared with flag-called deduplication.
|
|
49
|
+
# @param on_flag_definitions_updated [Proc, nil] Internal callback after definitions are applied or discarded,
|
|
50
|
+
# called while holding flag_definitions_update_mutex.
|
|
48
51
|
def initialize(
|
|
49
52
|
polling_interval,
|
|
50
53
|
secret_key,
|
|
@@ -55,7 +58,9 @@ module PostHog
|
|
|
55
58
|
flag_definition_cache_provider: nil,
|
|
56
59
|
feature_flag_request_max_retries: nil,
|
|
57
60
|
async_load: false,
|
|
58
|
-
user_agent: "posthog-ruby/#{PostHog::VERSION}"
|
|
61
|
+
user_agent: "posthog-ruby/#{PostHog::VERSION}",
|
|
62
|
+
on_flag_definitions_updated: nil,
|
|
63
|
+
flag_definitions_update_mutex: Mutex.new
|
|
59
64
|
)
|
|
60
65
|
@polling_interval = polling_interval || Defaults::FeatureFlags::POLLING_INTERVAL_SECONDS
|
|
61
66
|
@secret_key = secret_key
|
|
@@ -75,10 +80,17 @@ module PostHog
|
|
|
75
80
|
@flag_definitions_loaded_at = Concurrent::AtomicReference.new(nil)
|
|
76
81
|
@async_load = async_load
|
|
77
82
|
@user_agent = user_agent
|
|
83
|
+
@on_flag_definitions_updated = on_flag_definitions_updated
|
|
84
|
+
@flag_definitions_update_mutex = flag_definitions_update_mutex
|
|
78
85
|
# Server-controlled gate for minimal `$feature_flag_called` events, read
|
|
79
86
|
# from the top-level `minimal_flag_called_events` key of the local
|
|
80
87
|
# evaluation definitions payload. false when the server does not send it.
|
|
81
88
|
@minimal_flag_called_events = false
|
|
89
|
+
@definition_snapshot = Concurrent::AtomicReference.new({
|
|
90
|
+
flags: @feature_flags, flags_by_key: @feature_flags_by_key,
|
|
91
|
+
group_type_mapping: @group_type_mapping, cohorts: @cohorts,
|
|
92
|
+
minimal_flag_called_events: @minimal_flag_called_events, property_matching_version: 1
|
|
93
|
+
}.freeze)
|
|
82
94
|
@flag_definition_cache_provider = flag_definition_cache_provider
|
|
83
95
|
FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
|
|
84
96
|
|
|
@@ -214,12 +226,14 @@ module PostHog
|
|
|
214
226
|
groups = {},
|
|
215
227
|
person_properties = {},
|
|
216
228
|
group_properties = {},
|
|
217
|
-
only_evaluate_locally = false
|
|
229
|
+
only_evaluate_locally = false,
|
|
230
|
+
snapshot: nil
|
|
218
231
|
)
|
|
219
232
|
key = key.to_s
|
|
220
233
|
|
|
221
234
|
# make sure they're loaded on first run
|
|
222
235
|
load_feature_flags
|
|
236
|
+
snapshot ||= _evaluation_snapshot
|
|
223
237
|
|
|
224
238
|
symbolize_keys! groups
|
|
225
239
|
symbolize_keys! person_properties
|
|
@@ -231,12 +245,13 @@ module PostHog
|
|
|
231
245
|
|
|
232
246
|
response = nil
|
|
233
247
|
payload = nil
|
|
234
|
-
feature_flag =
|
|
248
|
+
feature_flag = snapshot[:flags_by_key]&.[](key)
|
|
235
249
|
|
|
236
250
|
unless feature_flag.nil?
|
|
237
251
|
begin
|
|
238
|
-
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties
|
|
239
|
-
|
|
252
|
+
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties,
|
|
253
|
+
snapshot: snapshot)
|
|
254
|
+
payload = _compute_flag_payload_locally(key, response, snapshot: snapshot) unless response.nil?
|
|
240
255
|
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
|
241
256
|
rescue RequiresServerEvaluation, InconclusiveMatchError => e
|
|
242
257
|
logger.debug "Failed to compute flag #{key} locally: #{e}"
|
|
@@ -255,7 +270,7 @@ module PostHog
|
|
|
255
270
|
# Locally-evaluated flags read it from the definitions payload; remotely
|
|
256
271
|
# evaluated flags read it from the /flags response. nil when the signal
|
|
257
272
|
# is unavailable, which fails safe to the full event.
|
|
258
|
-
minimal_flag_called_events =
|
|
273
|
+
minimal_flag_called_events = snapshot[:minimal_flag_called_events] if flag_was_locally_evaluated
|
|
259
274
|
|
|
260
275
|
request_id = nil
|
|
261
276
|
evaluated_at = nil
|
|
@@ -342,17 +357,19 @@ module PostHog
|
|
|
342
357
|
raise_on_error = false
|
|
343
358
|
)
|
|
344
359
|
load_feature_flags
|
|
360
|
+
snapshot = _evaluation_snapshot
|
|
345
361
|
|
|
346
362
|
flags = {}
|
|
347
363
|
payloads = {}
|
|
348
|
-
fallback_to_server =
|
|
364
|
+
fallback_to_server = snapshot[:flags].empty?
|
|
349
365
|
request_id = nil # Only for /flags requests
|
|
350
366
|
evaluated_at = nil # Only for /flags requests
|
|
351
367
|
|
|
352
|
-
|
|
353
|
-
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties
|
|
368
|
+
snapshot[:flags].each do |flag|
|
|
369
|
+
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties,
|
|
370
|
+
snapshot: snapshot)
|
|
354
371
|
flags[flag[:key]] = match_value
|
|
355
|
-
match_payload = _compute_flag_payload_locally(flag[:key], match_value)
|
|
372
|
+
match_payload = _compute_flag_payload_locally(flag[:key], match_value, snapshot: snapshot)
|
|
356
373
|
payloads[flag[:key]] = match_payload if match_payload
|
|
357
374
|
rescue RequiresServerEvaluation, InconclusiveMatchError
|
|
358
375
|
fallback_to_server = true
|
|
@@ -432,6 +449,8 @@ module PostHog
|
|
|
432
449
|
only_evaluate_locally = false
|
|
433
450
|
)
|
|
434
451
|
key = key.to_s
|
|
452
|
+
load_feature_flags
|
|
453
|
+
snapshot = _evaluation_snapshot
|
|
435
454
|
|
|
436
455
|
if match_value.nil?
|
|
437
456
|
match_value = get_feature_flag(
|
|
@@ -440,11 +459,12 @@ module PostHog
|
|
|
440
459
|
groups,
|
|
441
460
|
person_properties,
|
|
442
461
|
group_properties,
|
|
443
|
-
true
|
|
462
|
+
true,
|
|
463
|
+
snapshot: snapshot
|
|
444
464
|
)[0]
|
|
445
465
|
end
|
|
446
466
|
response = nil
|
|
447
|
-
response = _compute_flag_payload_locally(key, match_value) unless match_value.nil?
|
|
467
|
+
response = _compute_flag_payload_locally(key, match_value, snapshot: snapshot) unless match_value.nil?
|
|
448
468
|
if response.nil? && !only_evaluate_locally
|
|
449
469
|
flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
|
450
470
|
response = flags_payloads[key.downcase] || nil
|
|
@@ -615,14 +635,69 @@ module PostHog
|
|
|
615
635
|
end
|
|
616
636
|
end
|
|
617
637
|
|
|
618
|
-
|
|
638
|
+
# Service legacy classifies the entire filter, not individual array members.
|
|
639
|
+
def self.boolean_like?(value)
|
|
640
|
+
case value
|
|
641
|
+
when true, false then true
|
|
642
|
+
when String then %w[true false].include?(value.downcase)
|
|
643
|
+
when Array then value.all? { |member| boolean_like?(member) }
|
|
644
|
+
else false
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def self.legacy_truthy?(value)
|
|
649
|
+
case value
|
|
650
|
+
when true then true
|
|
651
|
+
when String then value.downcase == 'true'
|
|
652
|
+
when Array then value.all? { |member| legacy_truthy?(member) }
|
|
653
|
+
else false
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
def self.sorted_composite(value)
|
|
658
|
+
case value
|
|
659
|
+
when Hash
|
|
660
|
+
value.transform_keys(&:to_s).sort.to_h.transform_values { |member| sorted_composite(member) }
|
|
661
|
+
when Array
|
|
662
|
+
value.map { |member| sorted_composite(member) }
|
|
663
|
+
else
|
|
664
|
+
value
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def self.property_string(value)
|
|
669
|
+
# Keep numeric normalization unchanged; null and composites need JSON rather
|
|
670
|
+
# than Ruby's nil.to_s / Array#to_s representations.
|
|
671
|
+
case value
|
|
672
|
+
when nil, Array, Hash then JSON.generate(sorted_composite(value))
|
|
673
|
+
else value.to_s
|
|
674
|
+
end.downcase
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def self.exact_property_match?(filter, value, property_matching_version)
|
|
678
|
+
return legacy_truthy?(filter) == legacy_truthy?(value) if property_matching_version != 2 && boolean_like?(filter)
|
|
679
|
+
return legacy_truthy?(value) if filter.is_a?(Array) && filter.empty?
|
|
680
|
+
|
|
681
|
+
if filter.is_a?(Array)
|
|
682
|
+
filter.any? { |member| property_string(member) == property_string(value) }
|
|
683
|
+
else
|
|
684
|
+
property_string(filter) == property_string(value)
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
private_class_method :boolean_like?, :legacy_truthy?, :sorted_composite, :property_string, :exact_property_match?
|
|
689
|
+
|
|
690
|
+
def self.match_property(property, property_values, cohort_properties = {}, property_matching_version: 1)
|
|
619
691
|
# only looks for matches where key exists in property_values
|
|
620
692
|
|
|
621
693
|
PostHog::Utils.symbolize_keys! property
|
|
622
694
|
PostHog::Utils.symbolize_keys! property_values
|
|
623
695
|
|
|
624
696
|
# Handle cohort properties
|
|
625
|
-
|
|
697
|
+
if extract_value(property, :type) == 'cohort'
|
|
698
|
+
return match_cohort(property, property_values, cohort_properties,
|
|
699
|
+
property_matching_version: property_matching_version)
|
|
700
|
+
end
|
|
626
701
|
|
|
627
702
|
key = property[:key].to_sym
|
|
628
703
|
value = property[:value]
|
|
@@ -638,18 +713,8 @@ module PostHog
|
|
|
638
713
|
|
|
639
714
|
case operator
|
|
640
715
|
when 'exact', 'is_not'
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'
|
|
644
|
-
|
|
645
|
-
return values_stringified.none?(override_value.to_s.downcase)
|
|
646
|
-
|
|
647
|
-
end
|
|
648
|
-
if operator == 'exact'
|
|
649
|
-
value.to_s.downcase == override_value.to_s.downcase
|
|
650
|
-
else
|
|
651
|
-
value.to_s.downcase != override_value.to_s.downcase
|
|
652
|
-
end
|
|
716
|
+
matches = exact_property_match?(value, override_value, property_matching_version)
|
|
717
|
+
operator == 'exact' ? matches : !matches
|
|
653
718
|
when 'is_set'
|
|
654
719
|
property_values.key?(key)
|
|
655
720
|
when 'icontains'
|
|
@@ -731,7 +796,7 @@ module PostHog
|
|
|
731
796
|
end
|
|
732
797
|
end
|
|
733
798
|
|
|
734
|
-
def self.match_cohort(property, property_values, cohort_properties)
|
|
799
|
+
def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
|
|
735
800
|
# Cohort properties are in the form of property groups like this:
|
|
736
801
|
# {
|
|
737
802
|
# "cohort_id" => {
|
|
@@ -749,10 +814,11 @@ module PostHog
|
|
|
749
814
|
"cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
|
750
815
|
end
|
|
751
816
|
|
|
752
|
-
match_property_group(property_group, property_values, cohort_properties
|
|
817
|
+
match_property_group(property_group, property_values, cohort_properties,
|
|
818
|
+
property_matching_version: property_matching_version)
|
|
753
819
|
end
|
|
754
820
|
|
|
755
|
-
def self.match_property_group(property_group, property_values, cohort_properties)
|
|
821
|
+
def self.match_property_group(property_group, property_values, cohort_properties, property_matching_version: 1)
|
|
756
822
|
return true if property_group.nil? || property_group.empty?
|
|
757
823
|
|
|
758
824
|
group_type = extract_value(property_group, :type)
|
|
@@ -761,9 +827,11 @@ module PostHog
|
|
|
761
827
|
return true if properties.nil? || properties.empty?
|
|
762
828
|
|
|
763
829
|
if nested_property_group?(properties)
|
|
764
|
-
match_nested_property_group(properties, group_type, property_values, cohort_properties
|
|
830
|
+
match_nested_property_group(properties, group_type, property_values, cohort_properties,
|
|
831
|
+
property_matching_version: property_matching_version)
|
|
765
832
|
else
|
|
766
|
-
match_regular_property_group(properties, group_type, property_values, cohort_properties
|
|
833
|
+
match_regular_property_group(properties, group_type, property_values, cohort_properties,
|
|
834
|
+
property_matching_version: property_matching_version)
|
|
767
835
|
end
|
|
768
836
|
end
|
|
769
837
|
|
|
@@ -788,16 +856,19 @@ module PostHog
|
|
|
788
856
|
first_property.key?(:values) || first_property.key?('values')
|
|
789
857
|
end
|
|
790
858
|
|
|
791
|
-
def self.match_nested_property_group(properties, group_type, property_values, cohort_properties
|
|
859
|
+
def self.match_nested_property_group(properties, group_type, property_values, cohort_properties,
|
|
860
|
+
property_matching_version: 1)
|
|
792
861
|
case group_type
|
|
793
862
|
when 'AND'
|
|
794
863
|
properties.each do |property|
|
|
795
|
-
return false unless match_property_group(property, property_values, cohort_properties
|
|
864
|
+
return false unless match_property_group(property, property_values, cohort_properties,
|
|
865
|
+
property_matching_version: property_matching_version)
|
|
796
866
|
end
|
|
797
867
|
true
|
|
798
868
|
when 'OR'
|
|
799
869
|
properties.each do |property|
|
|
800
|
-
return true if match_property_group(property, property_values, cohort_properties
|
|
870
|
+
return true if match_property_group(property, property_values, cohort_properties,
|
|
871
|
+
property_matching_version: property_matching_version)
|
|
801
872
|
end
|
|
802
873
|
false
|
|
803
874
|
else
|
|
@@ -805,7 +876,8 @@ module PostHog
|
|
|
805
876
|
end
|
|
806
877
|
end
|
|
807
878
|
|
|
808
|
-
def self.match_regular_property_group(properties, group_type, property_values, cohort_properties
|
|
879
|
+
def self.match_regular_property_group(properties, group_type, property_values, cohort_properties,
|
|
880
|
+
property_matching_version: 1)
|
|
809
881
|
# Validate group type upfront
|
|
810
882
|
raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
|
|
811
883
|
|
|
@@ -814,7 +886,8 @@ module PostHog
|
|
|
814
886
|
properties.each do |prop|
|
|
815
887
|
PostHog::Utils.symbolize_keys!(prop)
|
|
816
888
|
|
|
817
|
-
matches = match_property(prop, property_values, cohort_properties
|
|
889
|
+
matches = match_property(prop, property_values, cohort_properties,
|
|
890
|
+
property_matching_version: property_matching_version)
|
|
818
891
|
|
|
819
892
|
negated = prop[:negation] || false
|
|
820
893
|
final_result = negated ? !matches : matches
|
|
@@ -847,13 +920,14 @@ module PostHog
|
|
|
847
920
|
# @param properties [Hash] Person properties for evaluation
|
|
848
921
|
# @param cohort_properties [Hash] Cohort properties for evaluation
|
|
849
922
|
# @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
|
|
850
|
-
def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties
|
|
923
|
+
def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties,
|
|
924
|
+
snapshot: _evaluation_snapshot)
|
|
851
925
|
if property[:operator] != 'flag_evaluates_to'
|
|
852
926
|
# Should never happen, but just in case
|
|
853
927
|
raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
|
|
854
928
|
end
|
|
855
929
|
|
|
856
|
-
if
|
|
930
|
+
if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
|
|
857
931
|
# Cannot evaluate flag dependencies without required context
|
|
858
932
|
raise InconclusiveMatchError,
|
|
859
933
|
"Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
|
|
@@ -881,7 +955,7 @@ module PostHog
|
|
|
881
955
|
dependency_chain.each do |dep_flag_key|
|
|
882
956
|
unless evaluation_cache.key?(dep_flag_key)
|
|
883
957
|
# Need to evaluate this dependency first
|
|
884
|
-
dep_flag =
|
|
958
|
+
dep_flag = snapshot[:flags_by_key][dep_flag_key]
|
|
885
959
|
if dep_flag.nil?
|
|
886
960
|
# Missing flag dependency - cannot evaluate locally
|
|
887
961
|
evaluation_cache[dep_flag_key] = nil
|
|
@@ -898,7 +972,8 @@ module PostHog
|
|
|
898
972
|
distinct_id,
|
|
899
973
|
properties,
|
|
900
974
|
evaluation_cache,
|
|
901
|
-
cohort_properties
|
|
975
|
+
cohort_properties,
|
|
976
|
+
snapshot: snapshot
|
|
902
977
|
)
|
|
903
978
|
evaluation_cache[dep_flag_key] = dep_result
|
|
904
979
|
rescue InconclusiveMatchError => e
|
|
@@ -964,7 +1039,14 @@ module PostHog
|
|
|
964
1039
|
private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
|
|
965
1040
|
:match_nested_property_group, :match_regular_property_group
|
|
966
1041
|
|
|
967
|
-
|
|
1042
|
+
# Publish/capture all matching state together so a poll cannot switch semantics
|
|
1043
|
+
# (or dependency/cohort definitions) halfway through one local evaluation.
|
|
1044
|
+
def _evaluation_snapshot
|
|
1045
|
+
@definition_snapshot.value
|
|
1046
|
+
end
|
|
1047
|
+
|
|
1048
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {},
|
|
1049
|
+
snapshot: _evaluation_snapshot)
|
|
968
1050
|
raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
|
969
1051
|
|
|
970
1052
|
return false unless flag[:active]
|
|
@@ -981,11 +1063,12 @@ module PostHog
|
|
|
981
1063
|
local_person_properties = local_person_properties.merge(distinct_id: distinct_id)
|
|
982
1064
|
end
|
|
983
1065
|
|
|
984
|
-
return match_feature_flag_properties(flag, distinct_id, local_person_properties, evaluation_cache,
|
|
985
|
-
groups: groups, group_properties: group_properties
|
|
1066
|
+
return match_feature_flag_properties(flag, distinct_id, local_person_properties, evaluation_cache,
|
|
1067
|
+
snapshot[:cohorts], groups: groups, group_properties: group_properties,
|
|
1068
|
+
snapshot: snapshot)
|
|
986
1069
|
end
|
|
987
1070
|
|
|
988
|
-
group_name =
|
|
1071
|
+
group_name = snapshot[:group_type_mapping][aggregation_group_type_index.to_s.to_sym]
|
|
989
1072
|
|
|
990
1073
|
if group_name.nil?
|
|
991
1074
|
logger.warn(
|
|
@@ -1006,24 +1089,25 @@ module PostHog
|
|
|
1006
1089
|
|
|
1007
1090
|
focused_group_properties = group_properties[group_name_symbol]
|
|
1008
1091
|
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
|
|
1009
|
-
|
|
1092
|
+
snapshot[:cohorts], groups: groups, group_properties: group_properties,
|
|
1093
|
+
snapshot: snapshot)
|
|
1010
1094
|
end
|
|
1011
1095
|
|
|
1012
|
-
def _compute_flag_payload_locally(key, match_value)
|
|
1013
|
-
return nil if
|
|
1096
|
+
def _compute_flag_payload_locally(key, match_value, snapshot: _evaluation_snapshot)
|
|
1097
|
+
return nil if snapshot[:flags_by_key].nil?
|
|
1014
1098
|
|
|
1015
1099
|
key = key.to_s
|
|
1016
1100
|
response = nil
|
|
1017
1101
|
if [true, false].include? match_value
|
|
1018
|
-
response =
|
|
1102
|
+
response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_s.to_sym)
|
|
1019
1103
|
elsif match_value.is_a? String
|
|
1020
|
-
response =
|
|
1104
|
+
response = snapshot[:flags_by_key].dig(key, :filters, :payloads, match_value.to_sym)
|
|
1021
1105
|
end
|
|
1022
1106
|
response
|
|
1023
1107
|
end
|
|
1024
1108
|
|
|
1025
1109
|
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
|
|
1026
|
-
groups: {}, group_properties: {})
|
|
1110
|
+
groups: {}, group_properties: {}, snapshot: _evaluation_snapshot)
|
|
1027
1111
|
flag_filters = flag[:filters] || {}
|
|
1028
1112
|
|
|
1029
1113
|
flag_conditions = flag_filters[:groups] || []
|
|
@@ -1048,7 +1132,7 @@ module PostHog
|
|
|
1048
1132
|
if condition_aggregation.nil?
|
|
1049
1133
|
# Person condition under a mixed flag — caller already passed person props/bucketing.
|
|
1050
1134
|
else
|
|
1051
|
-
group_name =
|
|
1135
|
+
group_name = snapshot[:group_type_mapping][condition_aggregation.to_s.to_sym]
|
|
1052
1136
|
if group_name.nil? || !groups.key?(group_name.to_sym)
|
|
1053
1137
|
logger.debug do
|
|
1054
1138
|
"[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
|
|
@@ -1066,7 +1150,7 @@ module PostHog
|
|
|
1066
1150
|
end
|
|
1067
1151
|
|
|
1068
1152
|
case condition_match_outcome(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
|
|
1069
|
-
cohort_properties)
|
|
1153
|
+
cohort_properties, snapshot: snapshot)
|
|
1070
1154
|
when :match
|
|
1071
1155
|
variant_override = condition[:variant]
|
|
1072
1156
|
flag_multivariate = flag_filters[:multivariate] || {}
|
|
@@ -1100,9 +1184,10 @@ module PostHog
|
|
|
1100
1184
|
false
|
|
1101
1185
|
end
|
|
1102
1186
|
|
|
1103
|
-
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {}
|
|
1187
|
+
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
|
|
1188
|
+
snapshot: _evaluation_snapshot)
|
|
1104
1189
|
condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
|
|
1105
|
-
cohort_properties) == :match
|
|
1190
|
+
cohort_properties, snapshot: snapshot) == :match
|
|
1106
1191
|
end
|
|
1107
1192
|
|
|
1108
1193
|
# Evaluates a single condition group and returns a tri-state outcome:
|
|
@@ -1112,15 +1197,18 @@ module PostHog
|
|
|
1112
1197
|
# rollout percentage excluded the user
|
|
1113
1198
|
# Distinguishing :no_match from :out_of_rollout_bound lets the caller implement the
|
|
1114
1199
|
# early_exit behavior (mirrors the server-side Rust evaluation engine).
|
|
1115
|
-
def condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {}
|
|
1200
|
+
def condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {},
|
|
1201
|
+
snapshot: _evaluation_snapshot)
|
|
1116
1202
|
rollout_percentage = condition[:rollout_percentage]
|
|
1117
1203
|
|
|
1118
1204
|
unless (condition[:properties] || []).empty?
|
|
1119
1205
|
unless condition[:properties].all? do |prop|
|
|
1120
1206
|
if prop[:type] == 'flag'
|
|
1121
|
-
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties
|
|
1207
|
+
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties,
|
|
1208
|
+
snapshot: snapshot)
|
|
1122
1209
|
else
|
|
1123
|
-
FeatureFlagsPoller.match_property(prop, properties, cohort_properties
|
|
1210
|
+
FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
|
|
1211
|
+
property_matching_version: snapshot[:property_matching_version])
|
|
1124
1212
|
end
|
|
1125
1213
|
end
|
|
1126
1214
|
return :no_match
|
|
@@ -1221,14 +1309,23 @@ module PostHog
|
|
|
1221
1309
|
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
|
|
1222
1310
|
'Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
|
|
1223
1311
|
)
|
|
1224
|
-
@
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1312
|
+
@flag_definitions_update_mutex.synchronize do
|
|
1313
|
+
definitions_were_loaded = definitions_loaded?
|
|
1314
|
+
@feature_flags = Concurrent::Array.new
|
|
1315
|
+
@feature_flags_by_key = {}
|
|
1316
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
1317
|
+
@cohorts = Concurrent::Hash.new
|
|
1318
|
+
@flag_definitions_loaded_at.value = nil
|
|
1319
|
+
@minimal_flag_called_events = false
|
|
1320
|
+
@definition_snapshot.value = {
|
|
1321
|
+
flags: @feature_flags, flags_by_key: @feature_flags_by_key,
|
|
1322
|
+
group_type_mapping: @group_type_mapping, cohorts: @cohorts,
|
|
1323
|
+
minimal_flag_called_events: false, property_matching_version: 1
|
|
1324
|
+
}.freeze
|
|
1325
|
+
@loaded_flags_successfully_once.make_false
|
|
1326
|
+
@quota_limited.make_true
|
|
1327
|
+
@on_flag_definitions_updated&.call if definitions_were_loaded
|
|
1328
|
+
end
|
|
1232
1329
|
return
|
|
1233
1330
|
end
|
|
1234
1331
|
|
|
@@ -1247,11 +1344,13 @@ module PostHog
|
|
|
1247
1344
|
return unless @flag_definition_cache_provider
|
|
1248
1345
|
|
|
1249
1346
|
begin
|
|
1347
|
+
snapshot = _evaluation_snapshot
|
|
1250
1348
|
data = {
|
|
1251
|
-
flags:
|
|
1252
|
-
group_type_mapping:
|
|
1253
|
-
cohorts:
|
|
1254
|
-
minimal_flag_called_events:
|
|
1349
|
+
flags: snapshot[:flags].to_a,
|
|
1350
|
+
group_type_mapping: snapshot[:group_type_mapping].to_h,
|
|
1351
|
+
cohorts: snapshot[:cohorts].to_h,
|
|
1352
|
+
minimal_flag_called_events: snapshot[:minimal_flag_called_events],
|
|
1353
|
+
property_matching_version: snapshot[:property_matching_version]
|
|
1255
1354
|
}
|
|
1256
1355
|
@flag_definition_cache_provider.on_flag_definitions_received(data)
|
|
1257
1356
|
rescue StandardError => e
|
|
@@ -1265,21 +1364,33 @@ module PostHog
|
|
|
1265
1364
|
cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
|
|
1266
1365
|
minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')
|
|
1267
1366
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1367
|
+
property_matching_version = get_by_symbol_or_string_key(data, 'property_matching_version') || 1
|
|
1368
|
+
new_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
|
|
1270
1369
|
new_by_key = {}
|
|
1271
|
-
|
|
1370
|
+
new_flags.each do |flag|
|
|
1272
1371
|
new_by_key[flag[:key]] = flag unless flag[:key].nil?
|
|
1273
1372
|
end
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
@
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1373
|
+
new_group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
|
|
1374
|
+
new_cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
|
|
1375
|
+
|
|
1376
|
+
# A read of the new definitions must not record its event before the tracker reset.
|
|
1377
|
+
@flag_definitions_update_mutex.synchronize do
|
|
1378
|
+
@definition_snapshot.value = {
|
|
1379
|
+
flags: new_flags, flags_by_key: new_by_key,
|
|
1380
|
+
group_type_mapping: new_group_type_mapping, cohorts: new_cohorts,
|
|
1381
|
+
minimal_flag_called_events: minimal_flag_called_events == true,
|
|
1382
|
+
property_matching_version: property_matching_version
|
|
1383
|
+
}.freeze
|
|
1384
|
+
@feature_flags = new_flags
|
|
1385
|
+
@feature_flags_by_key = new_by_key
|
|
1386
|
+
@group_type_mapping = new_group_type_mapping
|
|
1387
|
+
@cohorts = new_cohorts
|
|
1388
|
+
@minimal_flag_called_events = minimal_flag_called_events == true
|
|
1389
|
+
@flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
|
|
1390
|
+
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
|
|
1391
|
+
@on_flag_definitions_updated&.call
|
|
1392
|
+
end
|
|
1393
|
+
logger.debug "Loaded #{new_flags.length} feature flags and #{new_cohorts.length} cohorts"
|
|
1283
1394
|
end
|
|
1284
1395
|
|
|
1285
1396
|
def _request_feature_flag_definitions(etag: nil)
|
|
@@ -14,11 +14,14 @@ module PostHog
|
|
|
14
14
|
#
|
|
15
15
|
# @!method flag_definitions
|
|
16
16
|
# Retrieve cached flag definitions. Return a Hash with +:flags+,
|
|
17
|
-
# +:group_type_mapping+, +:cohorts+, and +:
|
|
17
|
+
# +:group_type_mapping+, +:cohorts+, +:minimal_flag_called_events+, and +:property_matching_version+
|
|
18
18
|
# keys, or +nil+ if the cache is empty. Returning +nil+ triggers an API
|
|
19
19
|
# fetch when no flags are loaded yet (emergency fallback). Providers
|
|
20
20
|
# written before +:minimal_flag_called_events+ existed continue to work;
|
|
21
|
-
# a missing key is treated as +false+.
|
|
21
|
+
# a missing key is treated as +false+. Preserve +:property_matching_version+
|
|
22
|
+
# with the definitions: exactly +2+ selects explicit equality; missing/1
|
|
23
|
+
# (including older cache entries) selects service legacy boolean matching.
|
|
24
|
+
# A fresh entry without the version resets to legacy, even after version 2.
|
|
22
25
|
# @return [Hash, nil]
|
|
23
26
|
#
|
|
24
27
|
# @!method should_fetch_flag_definitions?
|
|
@@ -29,9 +32,10 @@ module PostHog
|
|
|
29
32
|
#
|
|
30
33
|
# @!method on_flag_definitions_received(data)
|
|
31
34
|
# Called after successfully fetching new definitions from the API.
|
|
32
|
-
# +data+ is a Hash with +:flags+, +:group_type_mapping+, +:cohorts+,
|
|
33
|
-
# +:minimal_flag_called_events+ keys
|
|
34
|
-
# wrappers). Store
|
|
35
|
+
# +data+ is a Hash with +:flags+, +:group_type_mapping+, +:cohorts+,
|
|
36
|
+
# +:minimal_flag_called_events+, and +:property_matching_version+ keys
|
|
37
|
+
# (plain Ruby types, not Concurrent:: wrappers). Store the entire snapshot
|
|
38
|
+
# together in your external cache, including version-only updates.
|
|
35
39
|
# @param data [Hash]
|
|
36
40
|
# @return [void]
|
|
37
41
|
#
|
data/lib/posthog/version.rb
CHANGED