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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48bec2a9194efaee9e0b2dee9c7ca1c3af4697a8b85b8256b34302cd9ab91818
4
- data.tar.gz: f5c4ae56806620e636e1d65fd20f18baed769b1f2e8fec9154c4cbb1515ebb80
3
+ metadata.gz: b4074fb22041c597b36316d8f5ecd613adeb94c6e5440af887dbb0e4ccf95eb1
4
+ data.tar.gz: b0a587ad8bef2e363321de12c0fdb7cce636fc80338caa3e4009855d4665e9ce
5
5
  SHA512:
6
- metadata.gz: 1aaf63d6764d231e7f461650651e419abebeaf26cb151ef168497c4ac0d8ce3ced14ef1e23a3308f14126f57aa78f337e2b9b3064983713d92dfea26cd7ffffa
7
- data.tar.gz: 3b851e37e73b2cb8e1dfe8439da24c45e341ca3adf5f1bfe89318e6f9a8270e7ee070216df644320ca16bc9236aa1f9e1b28d73f0968d63ca38ad80cc8dfe1c0
6
+ metadata.gz: 2aa7c9f54b15c7f4a53f3ed54419ac052a4ecbf9ab4b6454efe4fa4a99389e40ce35cf218aa19f5030e4cfb6ef3dcdb6d4669404687e050029d8ef77fb40a035
7
+ data.tar.gz: e3438baa285a1fab688cf3f7aa3192f87c5719c1dc086a43147d6f1e88609ec9281479a80984726c59a0ae26a1a1f2faf069e3428f9f7a6f9b6d89efb0e1c31b
@@ -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
- poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {}
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 = @feature_flags_poller.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 = @feature_flags_by_key&.[](key)
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
- payload = _compute_flag_payload_locally(key, response) unless response.nil?
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 = @minimal_flag_called_events if flag_was_locally_evaluated
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 = @feature_flags.empty?
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
- @feature_flags.each do |flag|
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
- def self.match_property(property, property_values, cohort_properties = {})
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
- return match_cohort(property, property_values, cohort_properties) if extract_value(property, :type) == 'cohort'
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
- if value.is_a?(Array)
642
- values_stringified = value.map { |val| val.to_s.downcase }
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 @feature_flags_by_key.nil? || evaluation_cache.nil?
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 = @feature_flags_by_key[dep_flag_key]
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
- def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
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, @cohorts,
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 = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
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
- @cohorts, groups: groups, group_properties: group_properties)
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 @feature_flags_by_key.nil?
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 = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_s.to_sym)
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 = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_sym)
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 = @group_type_mapping[condition_aggregation.to_s.to_sym]
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
- @feature_flags = Concurrent::Array.new
1225
- @feature_flags_by_key = {}
1226
- @group_type_mapping = Concurrent::Hash.new
1227
- @cohorts = Concurrent::Hash.new
1228
- @flag_definitions_loaded_at.value = nil
1229
- @minimal_flag_called_events = false
1230
- @loaded_flags_successfully_once.make_false
1231
- @quota_limited.make_true
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: @feature_flags.to_a,
1252
- group_type_mapping: @group_type_mapping.to_h,
1253
- cohorts: @cohorts.to_h,
1254
- minimal_flag_called_events: @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
- @feature_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
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
- @feature_flags.each do |flag|
1370
+ new_flags.each do |flag|
1272
1371
  new_by_key[flag[:key]] = flag unless flag[:key].nil?
1273
1372
  end
1274
- @feature_flags_by_key = new_by_key
1275
-
1276
- @group_type_mapping = Concurrent::Hash[deep_symbolize_keys(group_type_mapping)]
1277
- @cohorts = Concurrent::Hash[deep_symbolize_keys(cohorts)]
1278
- @minimal_flag_called_events = minimal_flag_called_events == true
1279
-
1280
- logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
1281
- @flag_definitions_loaded_at.value = (Time.now.to_f * 1000).to_i
1282
- @loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
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 +:minimal_flag_called_events+
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+, and
33
- # +:minimal_flag_called_events+ keys (plain Ruby types, not Concurrent::
34
- # wrappers). Store it in your external cache.
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
  #
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.23.6'
4
+ VERSION = '3.23.8'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: posthog-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.23.6
4
+ version: 3.23.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''