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 +4 -4
- data/lib/posthog/client.rb +12 -6
- data/lib/posthog/feature_flags.rb +155 -60
- data/lib/posthog/flag_definition_cache.rb +9 -5
- data/lib/posthog/mcp/README.md +53 -0
- data/lib/posthog/mcp/analytics.rb +116 -0
- data/lib/posthog/mcp/client.rb +246 -0
- data/lib/posthog/mcp/constants.rb +111 -0
- data/lib/posthog/mcp/conversation_id.rb +89 -0
- data/lib/posthog/mcp/event_builder.rb +168 -0
- data/lib/posthog/mcp/exceptions.rb +112 -0
- data/lib/posthog/mcp/identity.rb +194 -0
- data/lib/posthog/mcp/ids.rb +56 -0
- data/lib/posthog/mcp/instrumentation.rb +729 -0
- data/lib/posthog/mcp/intent.rb +101 -0
- data/lib/posthog/mcp/log.rb +37 -0
- data/lib/posthog/mcp/options.rb +128 -0
- data/lib/posthog/mcp/rack_middleware.rb +112 -0
- data/lib/posthog/mcp/request_scope.rb +72 -0
- data/lib/posthog/mcp/sanitization.rb +504 -0
- data/lib/posthog/mcp/schema_mutation.rb +179 -0
- data/lib/posthog/mcp/server_extension.rb +54 -0
- data/lib/posthog/mcp/session.rb +71 -0
- data/lib/posthog/mcp/session_token.rb +106 -0
- data/lib/posthog/mcp/sink.rb +98 -0
- data/lib/posthog/mcp/tools.rb +91 -0
- data/lib/posthog/mcp/tracking_data.rb +89 -0
- data/lib/posthog/mcp/truncation.rb +326 -0
- data/lib/posthog/mcp.rb +216 -0
- data/lib/posthog/version.rb +1 -1
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 471bfa76731003cb33a963c903119506339f2e5cc51eb5b326a8af5df7deaabd
|
|
4
|
+
data.tar.gz: c41215d0fcc1edc5894b513788ca1e5777e98061f6017e80ae845c46c009d906
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f183202aacd689a033e142a57d79355b115306fc39538220c7b002828883d15c8c61cccd29f3a40c7fde95df2ddefd55cebc4f0eab8fdf5cb9a800887b2536bd
|
|
7
|
+
data.tar.gz: 02123c015cb06e724ab63576948dbf928c7ce1f919772c0e5b70d69d62e2b7ce27ccbbb8a16b24de583c608d8566f5c7da806a2756cf6a182a40d7ced73d466e
|
data/lib/posthog/client.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
1263
|
-
group_type_mapping:
|
|
1264
|
-
cohorts:
|
|
1265
|
-
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 +:
|
|
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
|
#
|
|
@@ -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/`.
|