posthog-ruby 3.1.2 → 3.3.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 +29 -0
- data/lib/posthog/exception_capture.rb +116 -0
- data/lib/posthog/feature_flags.rb +138 -34
- data/lib/posthog/version.rb +1 -1
- data/lib/posthog.rb +1 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e895ef67d6f423e89151951d6868fa464c1efe64cee6dd37be8923351bf88467
|
4
|
+
data.tar.gz: 7bf79b2a93e79c8717e52ed2a6ca3261f0d3933859e05e0cee1c9c22aaaa77db
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 881cd65e3634adc8fe539fad751591876ffd070ed712b4de8533fdf38e231ba15fd126daa0afece1b1f06b27306c3afa00c1129e37c98202391593f439cfa886
|
7
|
+
data.tar.gz: 2333ef174f7f08858cf518d66ac60bd6c5fa8246d06b61c73c89c5e7d08cf2986b5b45bc952d6d18476b3b73257402ee5838a362da100da1b64d7c72f4ae38eb
|
data/lib/posthog/client.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'time'
|
4
|
+
require 'securerandom'
|
4
5
|
|
5
6
|
require 'posthog/defaults'
|
6
7
|
require 'posthog/logging'
|
@@ -9,6 +10,7 @@ require 'posthog/send_worker'
|
|
9
10
|
require 'posthog/noop_worker'
|
10
11
|
require 'posthog/feature_flags'
|
11
12
|
require 'posthog/send_feature_flags_options'
|
13
|
+
require 'posthog/exception_capture'
|
12
14
|
|
13
15
|
module PostHog
|
14
16
|
class Client
|
@@ -144,6 +146,33 @@ module PostHog
|
|
144
146
|
enqueue(FieldParser.parse_for_capture(attrs))
|
145
147
|
end
|
146
148
|
|
149
|
+
# Captures an exception as an event
|
150
|
+
#
|
151
|
+
# @param [Exception, String, Object] exception The exception to capture, a string message, or exception-like object
|
152
|
+
# @param [String] distinct_id The ID for the user (optional, defaults to a generated UUID)
|
153
|
+
# @param [Hash] additional_properties Additional properties to include with the exception event (optional)
|
154
|
+
def capture_exception(exception, distinct_id = nil, additional_properties = {})
|
155
|
+
exception_info = ExceptionCapture.build_parsed_exception(exception)
|
156
|
+
|
157
|
+
return if exception_info.nil?
|
158
|
+
|
159
|
+
no_distinct_id_was_provided = distinct_id.nil?
|
160
|
+
distinct_id ||= SecureRandom.uuid
|
161
|
+
|
162
|
+
properties = { '$exception_list' => [exception_info] }
|
163
|
+
properties.merge!(additional_properties) if additional_properties && !additional_properties.empty?
|
164
|
+
properties['$process_person_profile'] = false if no_distinct_id_was_provided
|
165
|
+
|
166
|
+
event_data = {
|
167
|
+
distinct_id: distinct_id,
|
168
|
+
event: '$exception',
|
169
|
+
properties: properties,
|
170
|
+
timestamp: Time.now
|
171
|
+
}
|
172
|
+
|
173
|
+
capture(event_data)
|
174
|
+
end
|
175
|
+
|
147
176
|
# Identifies a user
|
148
177
|
#
|
149
178
|
# @param [Hash] attrs
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Portions of this file are derived from getsentry/sentry-ruby by Software, Inc. dba Sentry
|
4
|
+
# Licensed under the MIT License
|
5
|
+
# - sentry-ruby/lib/sentry/interfaces/single_exception.rb
|
6
|
+
# - sentry-ruby/lib/sentry/interfaces/stacktrace_builder.rb
|
7
|
+
# - sentry-ruby/lib/sentry/backtrace.rb
|
8
|
+
# - sentry-ruby/lib/sentry/interfaces/stacktrace.rb
|
9
|
+
# - sentry-ruby/lib/sentry/linecache.rb
|
10
|
+
|
11
|
+
# 💖 open source (under MIT License)
|
12
|
+
|
13
|
+
module PostHog
|
14
|
+
module ExceptionCapture
|
15
|
+
RUBY_INPUT_FORMAT = /
|
16
|
+
^ \s* (?: [a-zA-Z]: | uri:classloader: )? ([^:]+ | <.*>):
|
17
|
+
(\d+)
|
18
|
+
(?: :in\s('|`)(?:([\w:]+)\#)?([^']+)')?$
|
19
|
+
/x
|
20
|
+
|
21
|
+
def self.build_parsed_exception(value)
|
22
|
+
title, message, backtrace = coerce_exception_input(value)
|
23
|
+
return nil if title.nil?
|
24
|
+
|
25
|
+
build_single_exception_from_data(title, message, backtrace)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.build_single_exception_from_data(title, message, backtrace)
|
29
|
+
{
|
30
|
+
'type' => title,
|
31
|
+
'value' => message || '',
|
32
|
+
'mechanism' => {
|
33
|
+
'type' => 'generic',
|
34
|
+
'handled' => true
|
35
|
+
},
|
36
|
+
'stacktrace' => build_stacktrace(backtrace)
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.build_stacktrace(backtrace)
|
41
|
+
return nil unless backtrace && !backtrace.empty?
|
42
|
+
|
43
|
+
frames = backtrace.first(50).map do |line|
|
44
|
+
parse_backtrace_line(line)
|
45
|
+
end.compact.reverse
|
46
|
+
|
47
|
+
{
|
48
|
+
'type' => 'raw',
|
49
|
+
'frames' => frames
|
50
|
+
}
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.parse_backtrace_line(line)
|
54
|
+
match = line.match(RUBY_INPUT_FORMAT)
|
55
|
+
return nil unless match
|
56
|
+
|
57
|
+
file = match[1]
|
58
|
+
lineno = match[2].to_i
|
59
|
+
method_name = match[5]
|
60
|
+
|
61
|
+
frame = {
|
62
|
+
'filename' => File.basename(file),
|
63
|
+
'abs_path' => file,
|
64
|
+
'lineno' => lineno,
|
65
|
+
'function' => method_name,
|
66
|
+
'in_app' => !gem_path?(file),
|
67
|
+
'platform' => 'ruby'
|
68
|
+
}
|
69
|
+
|
70
|
+
add_context_lines(frame, file, lineno) if File.exist?(file)
|
71
|
+
|
72
|
+
frame
|
73
|
+
end
|
74
|
+
|
75
|
+
def self.gem_path?(path)
|
76
|
+
path.include?('/gems/') ||
|
77
|
+
path.include?('/ruby/') ||
|
78
|
+
path.include?('/.rbenv/') ||
|
79
|
+
path.include?('/.rvm/')
|
80
|
+
end
|
81
|
+
|
82
|
+
def self.add_context_lines(frame, file_path, lineno, context_size = 5)
|
83
|
+
lines = File.readlines(file_path)
|
84
|
+
return if lines.empty?
|
85
|
+
|
86
|
+
return unless lineno.positive? && lineno <= lines.length
|
87
|
+
|
88
|
+
pre_context_start = [lineno - context_size, 1].max
|
89
|
+
post_context_end = [lineno + context_size, lines.length].min
|
90
|
+
|
91
|
+
frame['context_line'] = lines[lineno - 1].chomp
|
92
|
+
|
93
|
+
frame['pre_context'] = lines[(pre_context_start - 1)...(lineno - 1)].map(&:chomp) if pre_context_start < lineno
|
94
|
+
|
95
|
+
frame['post_context'] = lines[lineno...(post_context_end)].map(&:chomp) if post_context_end > lineno
|
96
|
+
rescue StandardError
|
97
|
+
# Silently ignore file read errors
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.coerce_exception_input(value)
|
101
|
+
if value.is_a?(String)
|
102
|
+
title = 'Error'
|
103
|
+
message = value
|
104
|
+
backtrace = nil
|
105
|
+
elsif value.respond_to?(:backtrace) && value.respond_to?(:message)
|
106
|
+
title = value.class.to_s
|
107
|
+
message = value.message || ''
|
108
|
+
backtrace = value.backtrace
|
109
|
+
else
|
110
|
+
return [nil, nil, nil]
|
111
|
+
end
|
112
|
+
|
113
|
+
[title, message, backtrace]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -186,7 +186,8 @@ module PostHog
|
|
186
186
|
|
187
187
|
if !flag_was_locally_evaluated && !only_evaluate_locally
|
188
188
|
begin
|
189
|
-
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)
|
190
191
|
if flags_data.key?(:featureFlags)
|
191
192
|
flags = stringify_keys(flags_data[:featureFlags] || {})
|
192
193
|
request_id = flags_data[:requestId]
|
@@ -526,8 +527,8 @@ module PostHog
|
|
526
527
|
|
527
528
|
properties.each do |prop|
|
528
529
|
PostHog::Utils.symbolize_keys!(prop)
|
529
|
-
|
530
|
-
|
530
|
+
|
531
|
+
matches = match_property(prop, property_values, cohort_properties)
|
531
532
|
|
532
533
|
negated = prop[:negation] || false
|
533
534
|
final_result = negated ? !matches : matches
|
@@ -549,37 +550,144 @@ module PostHog
|
|
549
550
|
group_type == 'AND'
|
550
551
|
end
|
551
552
|
|
552
|
-
|
553
|
-
|
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
|
554
623
|
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
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)
|
566
669
|
end
|
670
|
+
|
671
|
+
# Default case
|
672
|
+
false
|
567
673
|
end
|
568
674
|
|
569
675
|
private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
|
570
|
-
:match_nested_property_group, :match_regular_property_group
|
676
|
+
:match_nested_property_group, :match_regular_property_group
|
571
677
|
|
572
678
|
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
573
679
|
raise InconclusiveMatchError, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
574
680
|
|
575
681
|
return false unless flag[:active]
|
576
682
|
|
683
|
+
# Create evaluation cache for flag dependencies
|
684
|
+
evaluation_cache = {}
|
685
|
+
|
577
686
|
flag_filters = flag[:filters] || {}
|
578
687
|
|
579
688
|
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
580
689
|
if aggregation_group_type_index.nil?
|
581
|
-
return match_feature_flag_properties(flag, distinct_id, person_properties,
|
582
|
-
@cohorts)
|
690
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
|
583
691
|
end
|
584
692
|
|
585
693
|
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
@@ -602,7 +710,8 @@ module PostHog
|
|
602
710
|
end
|
603
711
|
|
604
712
|
focused_group_properties = group_properties[group_name_symbol]
|
605
|
-
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)
|
606
715
|
end
|
607
716
|
|
608
717
|
def _compute_flag_payload_locally(key, match_value)
|
@@ -617,7 +726,7 @@ module PostHog
|
|
617
726
|
response
|
618
727
|
end
|
619
728
|
|
620
|
-
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties = {})
|
729
|
+
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
|
621
730
|
flag_filters = flag[:filters] || {}
|
622
731
|
|
623
732
|
flag_conditions = flag_filters[:groups] || []
|
@@ -633,7 +742,7 @@ module PostHog
|
|
633
742
|
# NOTE: This NEEDS to be `each` because `each_key` breaks
|
634
743
|
# This is not a hash, it's just an array with 2 entries
|
635
744
|
sorted_flag_conditions.each do |condition, _idx| # rubocop:disable Style/HashEachMethods
|
636
|
-
if
|
745
|
+
if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties)
|
637
746
|
variant_override = condition[:variant]
|
638
747
|
flag_multivariate = flag_filters[:multivariate] || {}
|
639
748
|
flag_variants = flag_multivariate[:variants] || []
|
@@ -659,26 +768,21 @@ module PostHog
|
|
659
768
|
false
|
660
769
|
end
|
661
770
|
|
662
|
-
|
663
|
-
def is_condition_match(flag, distinct_id, condition, properties, cohort_properties = {}) # rubocop:disable Naming/PredicateName
|
771
|
+
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
|
664
772
|
rollout_percentage = condition[:rollout_percentage]
|
665
773
|
|
666
774
|
unless (condition[:properties] || []).empty?
|
667
|
-
|
668
|
-
# Skip flag dependencies as they are not supported in local evaluation
|
775
|
+
unless condition[:properties].all? do |prop|
|
669
776
|
if prop[:type] == 'flag'
|
670
|
-
|
671
|
-
|
672
|
-
|
673
|
-
)
|
674
|
-
next true
|
777
|
+
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties)
|
778
|
+
else
|
779
|
+
FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
|
675
780
|
end
|
676
|
-
FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
|
677
781
|
end
|
678
782
|
return false
|
679
|
-
elsif rollout_percentage.nil?
|
680
|
-
return true
|
681
783
|
end
|
784
|
+
|
785
|
+
return true if rollout_percentage.nil?
|
682
786
|
end
|
683
787
|
|
684
788
|
return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
|
data/lib/posthog/version.rb
CHANGED
data/lib/posthog.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: posthog-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- ''
|
8
|
+
autorequire:
|
8
9
|
bindir: bin
|
9
10
|
cert_chain: []
|
10
|
-
date: 2025-
|
11
|
+
date: 2025-09-23 00:00:00.000000000 Z
|
11
12
|
dependencies:
|
12
13
|
- !ruby/object:Gem::Dependency
|
13
14
|
name: concurrent-ruby
|
@@ -38,6 +39,7 @@ files:
|
|
38
39
|
- lib/posthog/backoff_policy.rb
|
39
40
|
- lib/posthog/client.rb
|
40
41
|
- lib/posthog/defaults.rb
|
42
|
+
- lib/posthog/exception_capture.rb
|
41
43
|
- lib/posthog/feature_flag.rb
|
42
44
|
- lib/posthog/feature_flags.rb
|
43
45
|
- lib/posthog/field_parser.rb
|
@@ -55,6 +57,7 @@ licenses:
|
|
55
57
|
- MIT
|
56
58
|
metadata:
|
57
59
|
rubygems_mfa_required: 'true'
|
60
|
+
post_install_message:
|
58
61
|
rdoc_options: []
|
59
62
|
require_paths:
|
60
63
|
- lib
|
@@ -69,7 +72,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
69
72
|
- !ruby/object:Gem::Version
|
70
73
|
version: '0'
|
71
74
|
requirements: []
|
72
|
-
rubygems_version: 3.
|
75
|
+
rubygems_version: 3.5.3
|
76
|
+
signing_key:
|
73
77
|
specification_version: 4
|
74
78
|
summary: PostHog library
|
75
79
|
test_files: []
|