posthog-ruby 3.23.7 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67be906751ad3545830e00ac558a3787ba935d08c5414b216b653b63a745a424
4
- data.tar.gz: 589fa3335b009dc16e22224e4708d55079e9b236c5c6cdfdba6f996fdb191af3
3
+ metadata.gz: 471bfa76731003cb33a963c903119506339f2e5cc51eb5b326a8af5df7deaabd
4
+ data.tar.gz: c41215d0fcc1edc5894b513788ca1e5777e98061f6017e80ae845c46c009d906
5
5
  SHA512:
6
- metadata.gz: 1d7c92afb919cfb24e8e3a9bcfd3605cb4fce07d784019f1dd80fd0c685bc4ddde18d5b0322091fe49ad0e474cedbcae3804baf624697be650eed0418a450cdd
7
- data.tar.gz: c9bec9d494e68a63b594c6960dc5e9d30d32d918c68df1a7dd83ba3a047179ff87bf5756f5dd0434ffde305b14a392457cbac7762c84e8ea91ed9839fab09763
6
+ metadata.gz: f183202aacd689a033e142a57d79355b115306fc39538220c7b002828883d15c8c61cccd29f3a40c7fde95df2ddefd55cebc4f0eab8fdf5cb9a800887b2536bd
7
+ data.tar.gz: 02123c015cb06e724ab63576948dbf928c7ce1f919772c0e5b70d69d62e2b7ce27ccbbb8a16b24de583c608d8566f5c7da806a2756cf6a182a40d7ced73d466e
@@ -302,6 +302,11 @@ module PostHog
302
302
 
303
303
  symbolize_keys! attrs
304
304
  minimal_flag_called_event = attrs.delete(:_minimal_flag_called_event) == true
305
+ # Integrations that ride on a host application's client (e.g. PostHog::MCP)
306
+ # may relabel a single event's `$lib`/`$lib_version` without relabeling the
307
+ # client or its User-Agent.
308
+ lib_override = attrs.delete(:_lib)
309
+ lib_version_override = attrs.delete(:_lib_version)
305
310
  enrich_capture_attrs_with_context(attrs)
306
311
 
307
312
  # Precedence: an explicit `flags` snapshot always wins, regardless of
@@ -370,8 +375,8 @@ module PostHog
370
375
  end
371
376
 
372
377
  attrs[:is_server] = @is_server
373
- attrs[:lib] = @lib
374
- attrs[:lib_version] = @lib_version
378
+ attrs[:lib] = lib_override || @lib
379
+ attrs[:lib_version] = (lib_version_override || @lib_version).to_s
375
380
  message = FieldParser.parse_for_capture(attrs)
376
381
  # Minimal events are built from the allowlist after full assembly so
377
382
  # context properties and parser-added metadata can never leak in.
@@ -659,7 +664,8 @@ module PostHog
659
664
  flag_keys_set = flag_keys&.to_set(&:to_s)
660
665
 
661
666
  @feature_flags_poller.load_feature_flags
662
- poller_flags_by_key = @feature_flags_poller.feature_flags_by_key || {}
667
+ definition_snapshot = @feature_flags_poller._evaluation_snapshot
668
+ poller_flags_by_key = definition_snapshot[:flags_by_key] || {}
663
669
 
664
670
  poller_flags_by_key.each do |key, definition|
665
671
  next if flag_keys_set && !flag_keys_set.include?(key.to_s)
@@ -667,7 +673,7 @@ module PostHog
667
673
  begin
668
674
  match = @feature_flags_poller.send(
669
675
  :_compute_flag_locally,
670
- definition, distinct_id, groups, person_properties, group_properties
676
+ definition, distinct_id, groups, person_properties, group_properties, snapshot: definition_snapshot
671
677
  )
672
678
  rescue PostHog::RequiresServerEvaluation, PostHog::InconclusiveMatchError, StandardError
673
679
  next
@@ -680,7 +686,7 @@ module PostHog
680
686
  enabled: match.is_a?(String) || (match ? true : false),
681
687
  variant: match.is_a?(String) ? match : nil,
682
688
  payload: FeatureFlagResult.parse_payload(
683
- @feature_flags_poller.send(:_compute_flag_payload_locally, key, match)
689
+ @feature_flags_poller.send(:_compute_flag_payload_locally, key, match, snapshot: definition_snapshot)
684
690
  ),
685
691
  id: definition[:id],
686
692
  version: nil,
@@ -699,7 +705,7 @@ module PostHog
699
705
  # the snapshot uses a remote /flags response, the response's top-level
700
706
  # `minimalFlagCalledEvents` field governs; a local-only snapshot reads
701
707
  # the gate polled with the flag definitions.
702
- minimal_flag_called_events = @feature_flags_poller.minimal_flag_called_events
708
+ minimal_flag_called_events = definition_snapshot[:minimal_flag_called_events]
703
709
 
704
710
  # Skip the remote `/flags` round-trip when the caller scoped the request
705
711
  # to a fixed set of `flag_keys` and we've already resolved every one of
@@ -86,6 +86,11 @@ module PostHog
86
86
  # from the top-level `minimal_flag_called_events` key of the local
87
87
  # evaluation definitions payload. false when the server does not send it.
88
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)
89
94
  @flag_definition_cache_provider = flag_definition_cache_provider
90
95
  FlagDefinitionCacheProvider.validate!(@flag_definition_cache_provider) if @flag_definition_cache_provider
91
96
 
@@ -221,12 +226,14 @@ module PostHog
221
226
  groups = {},
222
227
  person_properties = {},
223
228
  group_properties = {},
224
- only_evaluate_locally = false
229
+ only_evaluate_locally = false,
230
+ snapshot: nil
225
231
  )
226
232
  key = key.to_s
227
233
 
228
234
  # make sure they're loaded on first run
229
235
  load_feature_flags
236
+ snapshot ||= _evaluation_snapshot
230
237
 
231
238
  symbolize_keys! groups
232
239
  symbolize_keys! person_properties
@@ -238,12 +245,13 @@ module PostHog
238
245
 
239
246
  response = nil
240
247
  payload = nil
241
- feature_flag = @feature_flags_by_key&.[](key)
248
+ feature_flag = snapshot[:flags_by_key]&.[](key)
242
249
 
243
250
  unless feature_flag.nil?
244
251
  begin
245
- response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
246
- 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?
247
255
  logger.debug "Successfully computed flag locally: #{key} -> #{response}"
248
256
  rescue RequiresServerEvaluation, InconclusiveMatchError => e
249
257
  logger.debug "Failed to compute flag #{key} locally: #{e}"
@@ -262,7 +270,7 @@ module PostHog
262
270
  # Locally-evaluated flags read it from the definitions payload; remotely
263
271
  # evaluated flags read it from the /flags response. nil when the signal
264
272
  # is unavailable, which fails safe to the full event.
265
- 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
266
274
 
267
275
  request_id = nil
268
276
  evaluated_at = nil
@@ -349,17 +357,19 @@ module PostHog
349
357
  raise_on_error = false
350
358
  )
351
359
  load_feature_flags
360
+ snapshot = _evaluation_snapshot
352
361
 
353
362
  flags = {}
354
363
  payloads = {}
355
- fallback_to_server = @feature_flags.empty?
364
+ fallback_to_server = snapshot[:flags].empty?
356
365
  request_id = nil # Only for /flags requests
357
366
  evaluated_at = nil # Only for /flags requests
358
367
 
359
- @feature_flags.each do |flag|
360
- 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)
361
371
  flags[flag[:key]] = match_value
362
- match_payload = _compute_flag_payload_locally(flag[:key], match_value)
372
+ match_payload = _compute_flag_payload_locally(flag[:key], match_value, snapshot: snapshot)
363
373
  payloads[flag[:key]] = match_payload if match_payload
364
374
  rescue RequiresServerEvaluation, InconclusiveMatchError
365
375
  fallback_to_server = true
@@ -439,6 +449,8 @@ module PostHog
439
449
  only_evaluate_locally = false
440
450
  )
441
451
  key = key.to_s
452
+ load_feature_flags
453
+ snapshot = _evaluation_snapshot
442
454
 
443
455
  if match_value.nil?
444
456
  match_value = get_feature_flag(
@@ -447,11 +459,12 @@ module PostHog
447
459
  groups,
448
460
  person_properties,
449
461
  group_properties,
450
- true
462
+ true,
463
+ snapshot: snapshot
451
464
  )[0]
452
465
  end
453
466
  response = nil
454
- 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?
455
468
  if response.nil? && !only_evaluate_locally
456
469
  flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
457
470
  response = flags_payloads[key.downcase] || nil
@@ -622,14 +635,69 @@ module PostHog
622
635
  end
623
636
  end
624
637
 
625
- 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)
626
691
  # only looks for matches where key exists in property_values
627
692
 
628
693
  PostHog::Utils.symbolize_keys! property
629
694
  PostHog::Utils.symbolize_keys! property_values
630
695
 
631
696
  # Handle cohort properties
632
- 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
633
701
 
634
702
  key = property[:key].to_sym
635
703
  value = property[:value]
@@ -645,18 +713,8 @@ module PostHog
645
713
 
646
714
  case operator
647
715
  when 'exact', 'is_not'
648
- if value.is_a?(Array)
649
- values_stringified = value.map { |val| val.to_s.downcase }
650
- return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'
651
-
652
- return values_stringified.none?(override_value.to_s.downcase)
653
-
654
- end
655
- if operator == 'exact'
656
- value.to_s.downcase == override_value.to_s.downcase
657
- else
658
- value.to_s.downcase != override_value.to_s.downcase
659
- end
716
+ matches = exact_property_match?(value, override_value, property_matching_version)
717
+ operator == 'exact' ? matches : !matches
660
718
  when 'is_set'
661
719
  property_values.key?(key)
662
720
  when 'icontains'
@@ -738,7 +796,7 @@ module PostHog
738
796
  end
739
797
  end
740
798
 
741
- def self.match_cohort(property, property_values, cohort_properties)
799
+ def self.match_cohort(property, property_values, cohort_properties, property_matching_version: 1)
742
800
  # Cohort properties are in the form of property groups like this:
743
801
  # {
744
802
  # "cohort_id" => {
@@ -756,10 +814,11 @@ module PostHog
756
814
  "cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
757
815
  end
758
816
 
759
- 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)
760
819
  end
761
820
 
762
- 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)
763
822
  return true if property_group.nil? || property_group.empty?
764
823
 
765
824
  group_type = extract_value(property_group, :type)
@@ -768,9 +827,11 @@ module PostHog
768
827
  return true if properties.nil? || properties.empty?
769
828
 
770
829
  if nested_property_group?(properties)
771
- 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)
772
832
  else
773
- 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)
774
835
  end
775
836
  end
776
837
 
@@ -795,16 +856,19 @@ module PostHog
795
856
  first_property.key?(:values) || first_property.key?('values')
796
857
  end
797
858
 
798
- 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)
799
861
  case group_type
800
862
  when 'AND'
801
863
  properties.each do |property|
802
- 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)
803
866
  end
804
867
  true
805
868
  when 'OR'
806
869
  properties.each do |property|
807
- 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)
808
872
  end
809
873
  false
810
874
  else
@@ -812,7 +876,8 @@ module PostHog
812
876
  end
813
877
  end
814
878
 
815
- 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)
816
881
  # Validate group type upfront
817
882
  raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
818
883
 
@@ -821,7 +886,8 @@ module PostHog
821
886
  properties.each do |prop|
822
887
  PostHog::Utils.symbolize_keys!(prop)
823
888
 
824
- matches = match_property(prop, property_values, cohort_properties)
889
+ matches = match_property(prop, property_values, cohort_properties,
890
+ property_matching_version: property_matching_version)
825
891
 
826
892
  negated = prop[:negation] || false
827
893
  final_result = negated ? !matches : matches
@@ -854,13 +920,14 @@ module PostHog
854
920
  # @param properties [Hash] Person properties for evaluation
855
921
  # @param cohort_properties [Hash] Cohort properties for evaluation
856
922
  # @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
857
- 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)
858
925
  if property[:operator] != 'flag_evaluates_to'
859
926
  # Should never happen, but just in case
860
927
  raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
861
928
  end
862
929
 
863
- if @feature_flags_by_key.nil? || evaluation_cache.nil?
930
+ if snapshot[:flags_by_key].nil? || evaluation_cache.nil?
864
931
  # Cannot evaluate flag dependencies without required context
865
932
  raise InconclusiveMatchError,
866
933
  "Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
@@ -888,7 +955,7 @@ module PostHog
888
955
  dependency_chain.each do |dep_flag_key|
889
956
  unless evaluation_cache.key?(dep_flag_key)
890
957
  # Need to evaluate this dependency first
891
- dep_flag = @feature_flags_by_key[dep_flag_key]
958
+ dep_flag = snapshot[:flags_by_key][dep_flag_key]
892
959
  if dep_flag.nil?
893
960
  # Missing flag dependency - cannot evaluate locally
894
961
  evaluation_cache[dep_flag_key] = nil
@@ -905,7 +972,8 @@ module PostHog
905
972
  distinct_id,
906
973
  properties,
907
974
  evaluation_cache,
908
- cohort_properties
975
+ cohort_properties,
976
+ snapshot: snapshot
909
977
  )
910
978
  evaluation_cache[dep_flag_key] = dep_result
911
979
  rescue InconclusiveMatchError => e
@@ -971,7 +1039,14 @@ module PostHog
971
1039
  private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
972
1040
  :match_nested_property_group, :match_regular_property_group
973
1041
 
974
- 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)
975
1050
  raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
976
1051
 
977
1052
  return false unless flag[:active]
@@ -988,11 +1063,12 @@ module PostHog
988
1063
  local_person_properties = local_person_properties.merge(distinct_id: distinct_id)
989
1064
  end
990
1065
 
991
- return match_feature_flag_properties(flag, distinct_id, local_person_properties, evaluation_cache, @cohorts,
992
- 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)
993
1069
  end
994
1070
 
995
- 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]
996
1072
 
997
1073
  if group_name.nil?
998
1074
  logger.warn(
@@ -1013,24 +1089,25 @@ module PostHog
1013
1089
 
1014
1090
  focused_group_properties = group_properties[group_name_symbol]
1015
1091
  match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
1016
- @cohorts, groups: groups, group_properties: group_properties)
1092
+ snapshot[:cohorts], groups: groups, group_properties: group_properties,
1093
+ snapshot: snapshot)
1017
1094
  end
1018
1095
 
1019
- def _compute_flag_payload_locally(key, match_value)
1020
- 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?
1021
1098
 
1022
1099
  key = key.to_s
1023
1100
  response = nil
1024
1101
  if [true, false].include? match_value
1025
- 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)
1026
1103
  elsif match_value.is_a? String
1027
- 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)
1028
1105
  end
1029
1106
  response
1030
1107
  end
1031
1108
 
1032
1109
  def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {},
1033
- groups: {}, group_properties: {})
1110
+ groups: {}, group_properties: {}, snapshot: _evaluation_snapshot)
1034
1111
  flag_filters = flag[:filters] || {}
1035
1112
 
1036
1113
  flag_conditions = flag_filters[:groups] || []
@@ -1055,7 +1132,7 @@ module PostHog
1055
1132
  if condition_aggregation.nil?
1056
1133
  # Person condition under a mixed flag — caller already passed person props/bucketing.
1057
1134
  else
1058
- group_name = @group_type_mapping[condition_aggregation.to_s.to_sym]
1135
+ group_name = snapshot[:group_type_mapping][condition_aggregation.to_s.to_sym]
1059
1136
  if group_name.nil? || !groups.key?(group_name.to_sym)
1060
1137
  logger.debug do
1061
1138
  "[FEATURE FLAGS] Skipping group condition for flag '#{flag[:key]}': " \
@@ -1073,7 +1150,7 @@ module PostHog
1073
1150
  end
1074
1151
 
1075
1152
  case condition_match_outcome(flag, effective_bucketing, condition, effective_properties, evaluation_cache,
1076
- cohort_properties)
1153
+ cohort_properties, snapshot: snapshot)
1077
1154
  when :match
1078
1155
  variant_override = condition[:variant]
1079
1156
  flag_multivariate = flag_filters[:multivariate] || {}
@@ -1107,9 +1184,10 @@ module PostHog
1107
1184
  false
1108
1185
  end
1109
1186
 
1110
- 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)
1111
1189
  condition_match_outcome(flag, distinct_id, condition, properties, evaluation_cache,
1112
- cohort_properties) == :match
1190
+ cohort_properties, snapshot: snapshot) == :match
1113
1191
  end
1114
1192
 
1115
1193
  # Evaluates a single condition group and returns a tri-state outcome:
@@ -1119,15 +1197,18 @@ module PostHog
1119
1197
  # rollout percentage excluded the user
1120
1198
  # Distinguishing :no_match from :out_of_rollout_bound lets the caller implement the
1121
1199
  # early_exit behavior (mirrors the server-side Rust evaluation engine).
1122
- 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)
1123
1202
  rollout_percentage = condition[:rollout_percentage]
1124
1203
 
1125
1204
  unless (condition[:properties] || []).empty?
1126
1205
  unless condition[:properties].all? do |prop|
1127
1206
  if prop[:type] == 'flag'
1128
- 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)
1129
1209
  else
1130
- FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
1210
+ FeatureFlagsPoller.match_property(prop, properties, cohort_properties,
1211
+ property_matching_version: snapshot[:property_matching_version])
1131
1212
  end
1132
1213
  end
1133
1214
  return :no_match
@@ -1236,6 +1317,11 @@ module PostHog
1236
1317
  @cohorts = Concurrent::Hash.new
1237
1318
  @flag_definitions_loaded_at.value = nil
1238
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
1239
1325
  @loaded_flags_successfully_once.make_false
1240
1326
  @quota_limited.make_true
1241
1327
  @on_flag_definitions_updated&.call if definitions_were_loaded
@@ -1258,11 +1344,13 @@ module PostHog
1258
1344
  return unless @flag_definition_cache_provider
1259
1345
 
1260
1346
  begin
1347
+ snapshot = _evaluation_snapshot
1261
1348
  data = {
1262
- flags: @feature_flags.to_a,
1263
- group_type_mapping: @group_type_mapping.to_h,
1264
- cohorts: @cohorts.to_h,
1265
- 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]
1266
1354
  }
1267
1355
  @flag_definition_cache_provider.on_flag_definitions_received(data)
1268
1356
  rescue StandardError => e
@@ -1276,6 +1364,7 @@ module PostHog
1276
1364
  cohorts = get_by_symbol_or_string_key(data, 'cohorts') || {}
1277
1365
  minimal_flag_called_events = get_by_symbol_or_string_key(data, 'minimal_flag_called_events')
1278
1366
 
1367
+ property_matching_version = get_by_symbol_or_string_key(data, 'property_matching_version') || 1
1279
1368
  new_flags = Concurrent::Array.new(flags.map { |f| deep_symbolize_keys(f) })
1280
1369
  new_by_key = {}
1281
1370
  new_flags.each do |flag|
@@ -1286,6 +1375,12 @@ module PostHog
1286
1375
 
1287
1376
  # A read of the new definitions must not record its event before the tracker reset.
1288
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
1289
1384
  @feature_flags = new_flags
1290
1385
  @feature_flags_by_key = new_by_key
1291
1386
  @group_type_mapping = new_group_type_mapping
@@ -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
  #
@@ -0,0 +1,53 @@
1
+ # PostHog MCP analytics for Ruby
2
+
3
+ > **Experimental and unsupported.** `PostHog::MCP` is not an officially supported PostHog SDK: no
4
+ > support is provided for it, and its API, options, and the captured `$mcp_*` event schema may change
5
+ > in a minor release. A one-line warning is logged when you require it. Bug reports and patches are
6
+ > welcome at https://github.com/PostHog/posthog-ruby/issues, but don't build production reporting on
7
+ > it yet.
8
+
9
+ Product analytics for [Model Context Protocol](https://modelcontextprotocol.io) servers built on the
10
+ official Ruby [`mcp`](https://rubygems.org/gems/mcp) gem. Wrap an `MCP::Server` so every tool call,
11
+ agent intent, handshake, listing, prompt, resource read, and failure is captured to PostHog.
12
+
13
+ **Documentation: https://posthog.com/docs/mcp-analytics** — setup, every option, the event and
14
+ property catalog, sessions on stateless/multi-pod servers, conversation ids, intent, identifying
15
+ users, privacy, and custom dispatchers. That's the single source of truth; this directory
16
+ deliberately keeps no second copy of it.
17
+
18
+ Install is just `gem 'posthog-ruby'`. `PostHog::MCP.instrument` needs the `mcp` gem (`>= 1.4`) at
19
+ runtime, but anyone wrapping a server already has it. `PostHog::MCP::Client` (custom dispatchers)
20
+ needs nothing beyond `posthog-ruby`.
21
+
22
+ ## Ruby-specific notes
23
+
24
+ These are the few things that differ from what the docs describe for the other SDKs.
25
+
26
+ * **`$lib` is per event.** MCP events report `$lib: "posthog-ruby-mcp"` while the client you pass in
27
+ keeps its own identity (`posthog-ruby` or `posthog-rails`) for everything else it sends, so
28
+ instrumenting a server inside a Rails app doesn't relabel the app's other events.
29
+ * **Truncation is tighter.** Events are truncated to fit the core client's 32KB per-message limit,
30
+ because posthog-ruby drops larger messages at batch time - truncating harder beats losing the
31
+ event.
32
+ * **Request scope needs Ruby 3.2+ to be inherited.** A custom event captured from a thread or fiber
33
+ a tool spawns is attributed to that request on 3.2+ (fiber storage); before 3.2 capture from the
34
+ tool body itself, or an HTTP server falls back to a standalone session.
35
+ * **Composed schemas are left alone.** A tool whose `input_schema` is `oneOf`/`allOf`/`anyOf` or a
36
+ `$ref` has nothing injected into it and nothing stripped from its calls.
37
+ * **stdio servers own `$stdout`.** The integration's own messages go only to the `logger:` you pass
38
+ (default: nowhere); the experimental notice and misconfiguration warnings go to stderr. Point the
39
+ core SDK's logger away from stdout too: `PostHog::Logging.logger = Logger.new($stderr)`.
40
+
41
+ ## Layout
42
+
43
+ | File | What it does |
44
+ |---|---|
45
+ | `mcp.rb` (parent dir) | `PostHog::MCP.instrument` and the public helpers |
46
+ | `server_extension.rb` | Prepended onto `MCP::Server` and the Streamable HTTP transport |
47
+ | `instrumentation.rb` | Per-request dispatch: intent, identity, sessions, recording |
48
+ | `client.rb` | `PostHog::MCP::Client` for custom dispatchers |
49
+ | `rack_middleware.rb` | `Mcp-Session-Id` tokens for a custom Rack stack |
50
+ | `sanitization.rb`, `truncation.rb` | Redaction and payload budgets |
51
+
52
+ Runnable example: [`examples/mcp_server.rb`](../../../examples/mcp_server.rb). Specs:
53
+ `spec/posthog/mcp/`.