posthog-ruby 3.1.1 → 3.2.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/bin/fmt +14 -0
- data/bin/test +3 -1
- data/lib/posthog/feature_flags.rb +254 -21
- data/lib/posthog/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 41c94649948ce4d629d17ccc7f5ed6649c7f9ed6e69ec491ca48bc638962cba4
|
4
|
+
data.tar.gz: 9a152790208aed756496e031faa0c2bf5a8dfba9bc8f74a1ac3510830132ac8a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b50f6538f3e285387f33a68a4c792c615f95d77826d333481637c7d3321a5490ce0be5f4bdf2ccb4ea914ae9ee5302dc488c9b3bf24f057a6c7d9c72799142d1
|
7
|
+
data.tar.gz: b34492ba3808627183010ca98d3e77356a873df98b5163854814e60f8225238db51cbafdb9786e9cf83a4295537c365e380094fe39d489bb518e979acd0578d4
|
data/bin/fmt
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
#/ Usage: bin/fmt [--check]
|
3
|
+
#/ Description: Format code using rubocop. Use --check to only check formatting without fixing.
|
4
|
+
source bin/helpers/_utils.sh
|
5
|
+
set_source_and_root_dir
|
6
|
+
|
7
|
+
# Check if --check flag is provided
|
8
|
+
if [[ "$1" == "--check" ]]; then
|
9
|
+
echo "Checking code formatting..."
|
10
|
+
rubocop
|
11
|
+
else
|
12
|
+
echo "Formatting code with rubocop..."
|
13
|
+
rubocop --autocorrect-all
|
14
|
+
fi
|
data/bin/test
CHANGED
@@ -30,6 +30,7 @@ module PostHog
|
|
30
30
|
@host = host
|
31
31
|
@feature_flags = Concurrent::Array.new
|
32
32
|
@group_type_mapping = Concurrent::Hash.new
|
33
|
+
@cohorts = Concurrent::Hash.new
|
33
34
|
@loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
|
34
35
|
@feature_flags_by_key = nil
|
35
36
|
@feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
|
@@ -185,7 +186,8 @@ module PostHog
|
|
185
186
|
|
186
187
|
if !flag_was_locally_evaluated && !only_evaluate_locally
|
187
188
|
begin
|
188
|
-
flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
|
189
|
+
flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
|
190
|
+
only_evaluate_locally, true)
|
189
191
|
if flags_data.key?(:featureFlags)
|
190
192
|
flags = stringify_keys(flags_data[:featureFlags] || {})
|
191
193
|
request_id = flags_data[:requestId]
|
@@ -367,13 +369,16 @@ module PostHog
|
|
367
369
|
parsed_dt
|
368
370
|
end
|
369
371
|
|
370
|
-
def self.match_property(property, property_values)
|
372
|
+
def self.match_property(property, property_values, cohort_properties = {})
|
371
373
|
# only looks for matches where key exists in property_values
|
372
374
|
# doesn't support operator is_not_set
|
373
375
|
|
374
376
|
PostHog::Utils.symbolize_keys! property
|
375
377
|
PostHog::Utils.symbolize_keys! property_values
|
376
378
|
|
379
|
+
# Handle cohort properties
|
380
|
+
return match_cohort(property, property_values, cohort_properties) if extract_value(property, :type) == 'cohort'
|
381
|
+
|
377
382
|
key = property[:key].to_sym
|
378
383
|
value = property[:value]
|
379
384
|
operator = property[:operator] || 'exact'
|
@@ -443,17 +448,247 @@ module PostHog
|
|
443
448
|
end
|
444
449
|
end
|
445
450
|
|
446
|
-
|
451
|
+
def self.match_cohort(property, property_values, cohort_properties)
|
452
|
+
# Cohort properties are in the form of property groups like this:
|
453
|
+
# {
|
454
|
+
# "cohort_id" => {
|
455
|
+
# "type" => "AND|OR",
|
456
|
+
# "values" => [{
|
457
|
+
# "key" => "property_name", "value" => "property_value"
|
458
|
+
# }]
|
459
|
+
# }
|
460
|
+
# }
|
461
|
+
cohort_id = extract_value(property, :value).to_s
|
462
|
+
property_group = find_cohort_property(cohort_properties, cohort_id)
|
463
|
+
|
464
|
+
raise InconclusiveMatchError, "can't match cohort without a given cohort property value" unless property_group
|
465
|
+
|
466
|
+
match_property_group(property_group, property_values, cohort_properties)
|
467
|
+
end
|
468
|
+
|
469
|
+
def self.match_property_group(property_group, property_values, cohort_properties)
|
470
|
+
return true if property_group.nil? || property_group.empty?
|
471
|
+
|
472
|
+
group_type = extract_value(property_group, :type)
|
473
|
+
properties = extract_value(property_group, :values)
|
474
|
+
|
475
|
+
return true if properties.nil? || properties.empty?
|
476
|
+
|
477
|
+
if nested_property_group?(properties)
|
478
|
+
match_nested_property_group(properties, group_type, property_values, cohort_properties)
|
479
|
+
else
|
480
|
+
match_regular_property_group(properties, group_type, property_values, cohort_properties)
|
481
|
+
end
|
482
|
+
end
|
483
|
+
|
484
|
+
def self.extract_value(hash, key)
|
485
|
+
return nil unless hash.is_a?(Hash)
|
486
|
+
|
487
|
+
hash[key] || hash[key.to_s]
|
488
|
+
end
|
489
|
+
|
490
|
+
def self.find_cohort_property(cohort_properties, cohort_id)
|
491
|
+
return nil unless cohort_properties.is_a?(Hash)
|
492
|
+
|
493
|
+
cohort_properties[cohort_id] || cohort_properties[cohort_id.to_sym]
|
494
|
+
end
|
495
|
+
|
496
|
+
def self.nested_property_group?(properties)
|
497
|
+
return false unless properties&.any?
|
498
|
+
|
499
|
+
first_property = properties[0]
|
500
|
+
return false unless first_property.is_a?(Hash)
|
501
|
+
|
502
|
+
first_property.key?(:values) || first_property.key?('values')
|
503
|
+
end
|
504
|
+
|
505
|
+
def self.match_nested_property_group(properties, group_type, property_values, cohort_properties)
|
506
|
+
case group_type
|
507
|
+
when 'AND'
|
508
|
+
properties.each do |property|
|
509
|
+
return false unless match_property_group(property, property_values, cohort_properties)
|
510
|
+
end
|
511
|
+
true
|
512
|
+
when 'OR'
|
513
|
+
properties.each do |property|
|
514
|
+
return true if match_property_group(property, property_values, cohort_properties)
|
515
|
+
end
|
516
|
+
false
|
517
|
+
else
|
518
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
|
519
|
+
end
|
520
|
+
end
|
521
|
+
|
522
|
+
def self.match_regular_property_group(properties, group_type, property_values, cohort_properties)
|
523
|
+
# Validate group type upfront
|
524
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
|
525
|
+
|
526
|
+
error_matching_locally = false
|
527
|
+
|
528
|
+
properties.each do |prop|
|
529
|
+
PostHog::Utils.symbolize_keys!(prop)
|
530
|
+
|
531
|
+
matches = match_property(prop, property_values, cohort_properties)
|
532
|
+
|
533
|
+
negated = prop[:negation] || false
|
534
|
+
final_result = negated ? !matches : matches
|
535
|
+
|
536
|
+
# Short-circuit based on group type
|
537
|
+
if group_type == 'AND'
|
538
|
+
return false unless final_result
|
539
|
+
elsif final_result # group_type == 'OR'
|
540
|
+
return true
|
541
|
+
end
|
542
|
+
rescue InconclusiveMatchError => e
|
543
|
+
PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
|
544
|
+
error_matching_locally = true
|
545
|
+
end
|
546
|
+
|
547
|
+
raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
|
548
|
+
|
549
|
+
# If we reach here, return default based on group type
|
550
|
+
group_type == 'AND'
|
551
|
+
end
|
552
|
+
|
553
|
+
# Evaluates a flag dependency property according to the dependency chain algorithm.
|
554
|
+
#
|
555
|
+
# @param property [Hash] Flag property with type="flag" and dependency_chain
|
556
|
+
# @param evaluation_cache [Hash] Cache for storing evaluation results
|
557
|
+
# @param distinct_id [String] The distinct ID being evaluated
|
558
|
+
# @param properties [Hash] Person properties for evaluation
|
559
|
+
# @param cohort_properties [Hash] Cohort properties for evaluation
|
560
|
+
# @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
|
561
|
+
def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties)
|
562
|
+
if property[:operator] != 'flag_evaluates_to'
|
563
|
+
# Should never happen, but just in case
|
564
|
+
raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
|
565
|
+
end
|
566
|
+
|
567
|
+
if @feature_flags_by_key.nil? || evaluation_cache.nil?
|
568
|
+
# Cannot evaluate flag dependencies without required context
|
569
|
+
raise InconclusiveMatchError,
|
570
|
+
"Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
|
571
|
+
'without feature flags loaded or evaluation_cache'
|
572
|
+
end
|
573
|
+
|
574
|
+
# Check if dependency_chain is present - it should always be provided for flag dependencies
|
575
|
+
unless property.key?(:dependency_chain)
|
576
|
+
# Missing dependency_chain indicates malformed server data
|
577
|
+
raise InconclusiveMatchError,
|
578
|
+
"Flag dependency property for '#{property[:key] || 'unknown'}' " \
|
579
|
+
"is missing required 'dependency_chain' field"
|
580
|
+
end
|
581
|
+
|
582
|
+
dependency_chain = property[:dependency_chain]
|
583
|
+
|
584
|
+
# Handle circular dependency (empty chain means circular)
|
585
|
+
if dependency_chain.empty?
|
586
|
+
PostHog::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
|
587
|
+
raise InconclusiveMatchError,
|
588
|
+
"Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
|
589
|
+
end
|
590
|
+
|
591
|
+
# Evaluate all dependencies in the chain order
|
592
|
+
dependency_chain.each do |dep_flag_key|
|
593
|
+
unless evaluation_cache.key?(dep_flag_key)
|
594
|
+
# Need to evaluate this dependency first
|
595
|
+
dep_flag = @feature_flags_by_key[dep_flag_key]
|
596
|
+
if dep_flag.nil?
|
597
|
+
# Missing flag dependency - cannot evaluate locally
|
598
|
+
evaluation_cache[dep_flag_key] = nil
|
599
|
+
raise InconclusiveMatchError,
|
600
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
|
601
|
+
elsif !dep_flag[:active]
|
602
|
+
# Check if the flag is active (same check as in _compute_flag_locally)
|
603
|
+
evaluation_cache[dep_flag_key] = false
|
604
|
+
else
|
605
|
+
# Recursively evaluate the dependency using existing instance method
|
606
|
+
begin
|
607
|
+
dep_result = match_feature_flag_properties(
|
608
|
+
dep_flag,
|
609
|
+
distinct_id,
|
610
|
+
properties,
|
611
|
+
evaluation_cache,
|
612
|
+
cohort_properties
|
613
|
+
)
|
614
|
+
evaluation_cache[dep_flag_key] = dep_result
|
615
|
+
rescue InconclusiveMatchError => e
|
616
|
+
# If we can't evaluate a dependency, store nil and propagate the error
|
617
|
+
evaluation_cache[dep_flag_key] = nil
|
618
|
+
raise InconclusiveMatchError,
|
619
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
|
620
|
+
end
|
621
|
+
end
|
622
|
+
end
|
623
|
+
|
624
|
+
# Check the cached result
|
625
|
+
cached_result = evaluation_cache[dep_flag_key]
|
626
|
+
if cached_result.nil?
|
627
|
+
# Previously inconclusive - raise error again
|
628
|
+
raise InconclusiveMatchError,
|
629
|
+
"Flag dependency '#{dep_flag_key}' was previously inconclusive"
|
630
|
+
elsif !cached_result
|
631
|
+
# Definitive False result - dependency failed
|
632
|
+
return false
|
633
|
+
end
|
634
|
+
end
|
635
|
+
|
636
|
+
# Get the expected value of the immediate dependency and the actual value
|
637
|
+
expected_value = property[:value]
|
638
|
+
# The flag we want to evaluate is defined by :key which should ALSO be the last key in the dependency chain
|
639
|
+
actual_value = evaluation_cache[property[:key]]
|
640
|
+
|
641
|
+
self.class.matches_dependency_value(expected_value, actual_value)
|
642
|
+
end
|
643
|
+
|
644
|
+
def self.matches_dependency_value(expected_value, actual_value)
|
645
|
+
# Check if the actual flag value matches the expected dependency value.
|
646
|
+
#
|
647
|
+
# - String variant case: check for exact match or boolean true
|
648
|
+
# - Boolean case: must match expected boolean value
|
649
|
+
#
|
650
|
+
# @param expected_value [Object] The expected value from the property
|
651
|
+
# @param actual_value [Object] The actual value returned by the flag evaluation
|
652
|
+
# @return [Boolean] True if the values match according to flag dependency rules
|
653
|
+
|
654
|
+
# String variant case - check for exact match or boolean true
|
655
|
+
if actual_value.is_a?(String) && !actual_value.empty?
|
656
|
+
if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
657
|
+
# Any variant matches boolean true
|
658
|
+
return expected_value
|
659
|
+
elsif expected_value.is_a?(String)
|
660
|
+
# variants are case-sensitive, hence our comparison is too
|
661
|
+
return actual_value == expected_value
|
662
|
+
else
|
663
|
+
return false
|
664
|
+
end
|
665
|
+
|
666
|
+
# Boolean case - must match expected boolean value
|
667
|
+
elsif actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass)
|
668
|
+
return actual_value == expected_value if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
669
|
+
end
|
670
|
+
|
671
|
+
# Default case
|
672
|
+
false
|
673
|
+
end
|
674
|
+
|
675
|
+
private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
|
676
|
+
:match_nested_property_group, :match_regular_property_group
|
447
677
|
|
448
678
|
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
449
679
|
raise InconclusiveMatchError, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
450
680
|
|
451
681
|
return false unless flag[:active]
|
452
682
|
|
683
|
+
# Create evaluation cache for flag dependencies
|
684
|
+
evaluation_cache = {}
|
685
|
+
|
453
686
|
flag_filters = flag[:filters] || {}
|
454
687
|
|
455
688
|
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
456
|
-
|
689
|
+
if aggregation_group_type_index.nil?
|
690
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
|
691
|
+
end
|
457
692
|
|
458
693
|
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
459
694
|
|
@@ -475,7 +710,8 @@ module PostHog
|
|
475
710
|
end
|
476
711
|
|
477
712
|
focused_group_properties = group_properties[group_name_symbol]
|
478
|
-
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties
|
713
|
+
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
|
714
|
+
@cohorts)
|
479
715
|
end
|
480
716
|
|
481
717
|
def _compute_flag_payload_locally(key, match_value)
|
@@ -490,7 +726,7 @@ module PostHog
|
|
490
726
|
response
|
491
727
|
end
|
492
728
|
|
493
|
-
def match_feature_flag_properties(flag, distinct_id, properties)
|
729
|
+
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
|
494
730
|
flag_filters = flag[:filters] || {}
|
495
731
|
|
496
732
|
flag_conditions = flag_filters[:groups] || []
|
@@ -506,7 +742,7 @@ module PostHog
|
|
506
742
|
# NOTE: This NEEDS to be `each` because `each_key` breaks
|
507
743
|
# This is not a hash, it's just an array with 2 entries
|
508
744
|
sorted_flag_conditions.each do |condition, _idx| # rubocop:disable Style/HashEachMethods
|
509
|
-
if
|
745
|
+
if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties)
|
510
746
|
variant_override = condition[:variant]
|
511
747
|
flag_multivariate = flag_filters[:multivariate] || {}
|
512
748
|
flag_variants = flag_multivariate[:variants] || []
|
@@ -532,26 +768,21 @@ module PostHog
|
|
532
768
|
false
|
533
769
|
end
|
534
770
|
|
535
|
-
|
536
|
-
def is_condition_match(flag, distinct_id, condition, properties) # rubocop:disable Naming/PredicateName
|
771
|
+
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
|
537
772
|
rollout_percentage = condition[:rollout_percentage]
|
538
773
|
|
539
774
|
unless (condition[:properties] || []).empty?
|
540
|
-
|
541
|
-
# Skip flag dependencies as they are not supported in local evaluation
|
775
|
+
unless condition[:properties].all? do |prop|
|
542
776
|
if prop[:type] == 'flag'
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
)
|
547
|
-
next true
|
777
|
+
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties)
|
778
|
+
else
|
779
|
+
FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
|
548
780
|
end
|
549
|
-
FeatureFlagsPoller.match_property(prop, properties)
|
550
781
|
end
|
551
782
|
return false
|
552
|
-
elsif rollout_percentage.nil?
|
553
|
-
return true
|
554
783
|
end
|
784
|
+
|
785
|
+
return true if rollout_percentage.nil?
|
555
786
|
end
|
556
787
|
|
557
788
|
return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
|
@@ -607,6 +838,7 @@ module PostHog
|
|
607
838
|
@feature_flags = Concurrent::Array.new
|
608
839
|
@feature_flags_by_key = {}
|
609
840
|
@group_type_mapping = Concurrent::Hash.new
|
841
|
+
@cohorts = Concurrent::Hash.new
|
610
842
|
@loaded_flags_successfully_once.make_false
|
611
843
|
@quota_limited.make_true
|
612
844
|
return
|
@@ -619,8 +851,9 @@ module PostHog
|
|
619
851
|
@feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
|
620
852
|
end
|
621
853
|
@group_type_mapping = res[:group_type_mapping] || {}
|
854
|
+
@cohorts = res[:cohorts] || {}
|
622
855
|
|
623
|
-
logger.debug "Loaded #{@feature_flags.length} feature flags"
|
856
|
+
logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
|
624
857
|
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
|
625
858
|
else
|
626
859
|
logger.debug "Failed to load feature flags: #{res}"
|
@@ -629,7 +862,7 @@ module PostHog
|
|
629
862
|
|
630
863
|
def _request_feature_flag_definitions
|
631
864
|
uri = URI("#{@host}/api/feature_flag/local_evaluation")
|
632
|
-
uri.query = URI.encode_www_form([['token', @project_api_key]])
|
865
|
+
uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
|
633
866
|
req = Net::HTTP::Get.new(uri)
|
634
867
|
req['Authorization'] = "Bearer #{@personal_api_key}"
|
635
868
|
|
data/lib/posthog/version.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: posthog-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- ''
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-08-
|
10
|
+
date: 2025-08-26 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: concurrent-ruby
|
@@ -30,6 +30,7 @@ executables:
|
|
30
30
|
extensions: []
|
31
31
|
extra_rdoc_files: []
|
32
32
|
files:
|
33
|
+
- bin/fmt
|
33
34
|
- bin/helpers/_utils.sh
|
34
35
|
- bin/posthog
|
35
36
|
- bin/test
|