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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47b1ce0c330a250b307dd757fa3024fcd516e8cf264576e52fde101c412d2e76
4
- data.tar.gz: '08725cd78aab55aa87b70ebcf65a2a061b641cbab043a2df61552eff199cc534'
3
+ metadata.gz: 41c94649948ce4d629d17ccc7f5ed6649c7f9ed6e69ec491ca48bc638962cba4
4
+ data.tar.gz: 9a152790208aed756496e031faa0c2bf5a8dfba9bc8f74a1ac3510830132ac8a
5
5
  SHA512:
6
- metadata.gz: 983808c3419cfab8772b3dbe175070d42dc10a357f1cb657bc36aa56389e867027e4c3ad3286d2d78eb2c232e2448f7190658beafcb846d2e5f72deb3d747d04
7
- data.tar.gz: 92aab3899eb59d728170098b0b6ff62badffbcc890080c67eaf9657399f3de4f3c96d6241942ec1bf64b06bdc53e9dd588845a4c4cbe8b079759cbe51a71b0df
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
@@ -4,4 +4,6 @@
4
4
  source bin/helpers/_utils.sh
5
5
  set_source_and_root_dir
6
6
 
7
- bundle exec rspec
7
+ # Use RUBYLIB to avoid bundler dependency issues
8
+ # This allows tests to run even if bundler can't resolve all dev dependencies
9
+ RUBYLIB=lib rspec "$@"
@@ -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, false, true)
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
- private
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
- return match_feature_flag_properties(flag, distinct_id, person_properties) if aggregation_group_type_index.nil?
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 is_condition_match(flag, distinct_id, condition, properties)
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
- # TODO: Rename to `condition_match?` in future version
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
- if !condition[:properties].all? do |prop|
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
- logger.warn(
544
- '[FEATURE FLAGS] Flag dependency filters are not supported in local evaluation. ' \
545
- "Skipping condition for flag '#{flag[:key]}' with dependency on flag '#{prop[:key]}'"
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PostHog
4
- VERSION = '3.1.1'
4
+ VERSION = '3.2.0'
5
5
  end
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.1.1
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-07 00:00:00.000000000 Z
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