posthog-ruby 1.3.0 → 2.1.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 +93 -34
- data/lib/posthog/defaults.rb +3 -0
- data/lib/posthog/feature_flags.rb +327 -57
- data/lib/posthog/field_parser.rb +44 -0
- data/lib/posthog/logging.rb +9 -0
- data/lib/posthog/send_worker.rb +1 -1
- data/lib/posthog/transport.rb +5 -3
- data/lib/posthog/utils.rb +43 -0
- data/lib/posthog/version.rb +1 -1
- metadata +16 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ef38f429ce00bcdcfc5c861fd163c420b9b3823b7cbcef4019a8bad580a6ad6f
|
4
|
+
data.tar.gz: '08effac4bc47b38a6e67fe2f578cd67a974a7462dcaa6e73e92ac11b5bb34333'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 86892110210900d5a3faa13fe2c335ce7d1ce0390390ff25c3d8a29510187d8829bda4795ead866471aaab7fa333f50a9b8251ff8e4ae47411d7a601d9f15eb2
|
7
|
+
data.tar.gz: 80374572728723780c1930555491445a4761685df6574305c0de73f9d0fbd1630924c5fd4412b469f735bd44d7657b7160d73ccb4094c978b58bb7494950bb32
|
data/lib/posthog/client.rb
CHANGED
@@ -20,9 +20,13 @@ class PostHog
|
|
20
20
|
# @option opts [Bool] :test_mode +true+ if messages should remain
|
21
21
|
# queued for testing. Defaults to +false+.
|
22
22
|
# @option opts [Proc] :on_error Handles error calls from the API.
|
23
|
+
# @option opts [String] :host Fully qualified hostname of the PostHog server. Defaults to `https://app.posthog.com`
|
24
|
+
# @option opts [Integer] :feature_flags_polling_interval How often to poll for feature flag definition changes. Measured in seconds, defaults to 30.
|
23
25
|
def initialize(opts = {})
|
24
26
|
symbolize_keys!(opts)
|
25
27
|
|
28
|
+
opts[:host] ||= 'https://app.posthog.com'
|
29
|
+
|
26
30
|
@queue = Queue.new
|
27
31
|
@api_key = opts[:api_key]
|
28
32
|
@max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
|
@@ -34,20 +38,19 @@ class PostHog
|
|
34
38
|
end
|
35
39
|
@worker_thread = nil
|
36
40
|
@feature_flags_poller = nil
|
37
|
-
@personal_api_key =
|
41
|
+
@personal_api_key = opts[:personal_api_key]
|
38
42
|
|
39
43
|
check_api_key!
|
40
44
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
end
|
45
|
+
@feature_flags_poller =
|
46
|
+
FeatureFlagsPoller.new(
|
47
|
+
opts[:feature_flags_polling_interval],
|
48
|
+
opts[:personal_api_key],
|
49
|
+
@api_key,
|
50
|
+
opts[:host]
|
51
|
+
)
|
52
|
+
|
53
|
+
@distinct_id_has_sent_flag_calls = SizeLimitedHash.new(Defaults::MAX_HASH_SIZE) { |hash, key| hash[key] = Array.new }
|
51
54
|
|
52
55
|
at_exit { @worker_thread && @worker_thread[:should_exit] = true }
|
53
56
|
end
|
@@ -82,9 +85,16 @@ class PostHog
|
|
82
85
|
#
|
83
86
|
# @option attrs [String] :event Event name
|
84
87
|
# @option attrs [Hash] :properties Event properties (optional)
|
88
|
+
# @option attrs [Bool] :send_feature_flags Whether to send feature flags with this event (optional)
|
85
89
|
# @macro common_attrs
|
86
90
|
def capture(attrs)
|
87
91
|
symbolize_keys! attrs
|
92
|
+
|
93
|
+
if attrs[:send_feature_flags]
|
94
|
+
feature_variants = @feature_flags_poller.get_feature_variants(attrs[:distinct_id], attrs[:groups])
|
95
|
+
attrs[:feature_variants] = feature_variants
|
96
|
+
end
|
97
|
+
|
88
98
|
enqueue(FieldParser.parse_for_capture(attrs))
|
89
99
|
end
|
90
100
|
|
@@ -99,6 +109,19 @@ class PostHog
|
|
99
109
|
enqueue(FieldParser.parse_for_identify(attrs))
|
100
110
|
end
|
101
111
|
|
112
|
+
# Identifies a group
|
113
|
+
#
|
114
|
+
# @param [Hash] attrs
|
115
|
+
#
|
116
|
+
# @option attrs [String] :group_type Group type
|
117
|
+
# @option attrs [String] :group_key Group key
|
118
|
+
# @option attrs [Hash] :properties Group properties (optional)
|
119
|
+
# @macro common_attrs
|
120
|
+
def group_identify(attrs)
|
121
|
+
symbolize_keys! attrs
|
122
|
+
enqueue(FieldParser.parse_for_group_identify(attrs))
|
123
|
+
end
|
124
|
+
|
102
125
|
# Aliases a user from one id to another
|
103
126
|
#
|
104
127
|
# @param [Hash] attrs
|
@@ -120,36 +143,72 @@ class PostHog
|
|
120
143
|
@queue.length
|
121
144
|
end
|
122
145
|
|
123
|
-
def is_feature_enabled(flag_key, distinct_id,
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
)
|
128
|
-
return
|
146
|
+
def is_feature_enabled(flag_key, distinct_id, groups: {}, person_properties: {}, group_properties: {}, only_evaluate_locally: false, send_feature_flag_events: true)
|
147
|
+
response = get_feature_flag(flag_key, distinct_id, groups: groups, person_properties: person_properties, group_properties: group_properties, only_evaluate_locally: only_evaluate_locally, send_feature_flag_events: send_feature_flag_events)
|
148
|
+
if response.nil?
|
149
|
+
return nil
|
129
150
|
end
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
151
|
+
!!response
|
152
|
+
end
|
153
|
+
|
154
|
+
# Returns whether the given feature flag is enabled for the given user or not
|
155
|
+
#
|
156
|
+
# @param [String] key The key of the feature flag
|
157
|
+
# @param [String] distinct_id The distinct id of the user
|
158
|
+
# @param [Hash] groups
|
159
|
+
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
160
|
+
# @param [Hash] group_properties
|
161
|
+
#
|
162
|
+
# @return [String, nil] The value of the feature flag
|
163
|
+
#
|
164
|
+
# The provided properties are used to calculate feature flags locally, if possible.
|
165
|
+
#
|
166
|
+
# `groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5",
|
167
|
+
# you would pass groups={"organization": "5"}.
|
168
|
+
# `group_properties` take the format: { group_type_name: { group_properties } }
|
169
|
+
# So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count,
|
170
|
+
# you'll send these as:
|
171
|
+
# ```ruby
|
172
|
+
# group_properties: {"organization": {"name": "PostHog", "employees": 11}}
|
173
|
+
# ```
|
174
|
+
def get_feature_flag(key, distinct_id, groups: {}, person_properties: {}, group_properties: {}, only_evaluate_locally: false, send_feature_flag_events: true)
|
175
|
+
feature_flag_response, flag_was_locally_evaluated = @feature_flags_poller.get_feature_flag(key, distinct_id, groups, person_properties, group_properties, only_evaluate_locally)
|
176
|
+
|
177
|
+
feature_flag_reported_key = "#{key}_#{feature_flag_response}"
|
178
|
+
if !@distinct_id_has_sent_flag_calls[distinct_id].include?(feature_flag_reported_key) && send_feature_flag_events
|
179
|
+
capture(
|
180
|
+
{
|
181
|
+
'distinct_id': distinct_id,
|
182
|
+
'event': '$feature_flag_called',
|
183
|
+
'properties': {
|
184
|
+
'$feature_flag' => key,
|
185
|
+
'$feature_flag_response' => feature_flag_response,
|
186
|
+
'locally_evaluated' => flag_was_locally_evaluated
|
187
|
+
},
|
188
|
+
'groups': groups,
|
143
189
|
}
|
144
|
-
|
145
|
-
|
146
|
-
|
190
|
+
)
|
191
|
+
@distinct_id_has_sent_flag_calls[distinct_id] << feature_flag_reported_key
|
192
|
+
end
|
193
|
+
feature_flag_response
|
194
|
+
end
|
195
|
+
|
196
|
+
# Returns all flags for a given user
|
197
|
+
#
|
198
|
+
# @param [String] distinct_id The distinct id of the user
|
199
|
+
# @param [Hash] groups
|
200
|
+
# @param [Hash] person_properties key-value pairs of properties to associate with the user.
|
201
|
+
# @param [Hash] group_properties
|
202
|
+
#
|
203
|
+
# @return [Hash] String (not symbol) key value pairs of flag and their values
|
204
|
+
def get_all_flags(distinct_id, groups: {}, person_properties: {}, group_properties: {}, only_evaluate_locally: false)
|
205
|
+
return @feature_flags_poller.get_all_flags(distinct_id, groups, person_properties, group_properties, only_evaluate_locally)
|
147
206
|
end
|
148
207
|
|
149
208
|
def reload_feature_flags
|
150
209
|
unless @personal_api_key
|
151
210
|
logger.error(
|
152
|
-
'You need to specify a personal_api_key to
|
211
|
+
'You need to specify a personal_api_key to locally evaluate feature flags'
|
153
212
|
)
|
154
213
|
return
|
155
214
|
end
|
data/lib/posthog/defaults.rb
CHANGED
@@ -4,116 +4,386 @@ require 'json'
|
|
4
4
|
require 'posthog/version'
|
5
5
|
require 'posthog/logging'
|
6
6
|
require 'digest'
|
7
|
+
|
7
8
|
class PostHog
|
9
|
+
|
10
|
+
class InconclusiveMatchError < StandardError
|
11
|
+
end
|
12
|
+
|
13
|
+
class DecideAPIError < StandardError
|
14
|
+
end
|
15
|
+
|
8
16
|
class FeatureFlagsPoller
|
9
17
|
include PostHog::Logging
|
18
|
+
include PostHog::Utils
|
10
19
|
|
11
20
|
def initialize(polling_interval, personal_api_key, project_api_key, host)
|
12
|
-
@polling_interval = polling_interval ||
|
21
|
+
@polling_interval = polling_interval || 30
|
13
22
|
@personal_api_key = personal_api_key
|
14
23
|
@project_api_key = project_api_key
|
15
|
-
@host = host
|
24
|
+
@host = host
|
16
25
|
@feature_flags = Concurrent::Array.new
|
26
|
+
@group_type_mapping = Concurrent::Hash.new
|
17
27
|
@loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
|
18
28
|
|
19
29
|
@task =
|
20
30
|
Concurrent::TimerTask.new(
|
21
31
|
execution_interval: polling_interval,
|
22
|
-
timeout_interval: 15
|
23
32
|
) { _load_feature_flags }
|
24
33
|
|
25
|
-
#
|
26
|
-
|
27
|
-
|
34
|
+
# If no personal API key, disable local evaluation & thus polling for definitions
|
35
|
+
if @personal_api_key.nil?
|
36
|
+
logger.info "No personal API key provided, disabling local evaluation"
|
37
|
+
@loaded_flags_successfully_once.make_true
|
38
|
+
else
|
39
|
+
# load once before timer
|
40
|
+
load_feature_flags
|
41
|
+
@task.execute
|
42
|
+
end
|
28
43
|
end
|
29
44
|
|
30
|
-
def
|
45
|
+
def load_feature_flags(force_reload = false)
|
46
|
+
if @loaded_flags_successfully_once.false? || force_reload
|
47
|
+
_load_feature_flags
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def get_feature_variants(distinct_id, groups={}, person_properties={}, group_properties={})
|
52
|
+
|
53
|
+
request_data = {
|
54
|
+
"distinct_id": distinct_id,
|
55
|
+
"groups": groups,
|
56
|
+
"person_properties": person_properties,
|
57
|
+
"group_properties": group_properties,
|
58
|
+
}
|
59
|
+
|
60
|
+
decide_data = _request_feature_flag_evaluation(request_data)
|
61
|
+
|
62
|
+
if !decide_data.key?(:featureFlags)
|
63
|
+
raise DecideAPIError.new(decide_data.to_json)
|
64
|
+
else
|
65
|
+
stringify_keys(decide_data[:featureFlags] || {})
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def get_feature_flag(key, distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
31
70
|
# make sure they're loaded on first run
|
32
71
|
load_feature_flags
|
33
72
|
|
34
|
-
|
73
|
+
symbolize_keys! groups
|
74
|
+
symbolize_keys! person_properties
|
75
|
+
symbolize_keys! group_properties
|
35
76
|
|
36
|
-
|
77
|
+
group_properties.each do |key, value|
|
78
|
+
symbolize_keys! value
|
79
|
+
end
|
37
80
|
|
81
|
+
response = nil
|
82
|
+
feature_flag = nil
|
38
83
|
|
39
84
|
@feature_flags.each do |flag|
|
40
|
-
if key == flag[
|
85
|
+
if key == flag[:key]
|
41
86
|
feature_flag = flag
|
42
87
|
break
|
43
88
|
end
|
44
89
|
end
|
45
90
|
|
46
|
-
|
91
|
+
if !feature_flag.nil?
|
92
|
+
begin
|
93
|
+
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
|
94
|
+
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
95
|
+
rescue InconclusiveMatchError => e
|
96
|
+
logger.debug "Failed to compute flag #{key} locally: #{e}"
|
97
|
+
rescue StandardError => e
|
98
|
+
logger.error "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}"
|
99
|
+
end
|
100
|
+
end
|
47
101
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
102
|
+
flag_was_locally_evaluated = !response.nil?
|
103
|
+
|
104
|
+
if !flag_was_locally_evaluated && !only_evaluate_locally
|
105
|
+
begin
|
106
|
+
flags = get_feature_variants(distinct_id, groups, person_properties, group_properties)
|
107
|
+
response = flags[key]
|
108
|
+
if response.nil?
|
109
|
+
response = false
|
110
|
+
end
|
111
|
+
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
112
|
+
rescue StandardError => e
|
113
|
+
logger.error "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}"
|
53
114
|
end
|
54
|
-
if feature_flag['is_simple_flag']
|
55
|
-
return is_simple_flag_enabled(key, distinct_id, flag_rollout_pctg)
|
56
|
-
else
|
57
|
-
data = { 'distinct_id' => distinct_id }
|
58
|
-
res = _request('POST', 'decide', false, data)
|
59
|
-
return res['featureFlags'].include? key
|
60
115
|
end
|
61
116
|
|
62
|
-
|
117
|
+
[response, flag_was_locally_evaluated]
|
63
118
|
end
|
64
119
|
|
65
|
-
def
|
66
|
-
hash
|
67
|
-
return(
|
68
|
-
(Integer(hash[0..14], 16).to_f / 0xfffffffffffffff) <=
|
69
|
-
(rollout_percentage / 100)
|
70
|
-
)
|
71
|
-
end
|
120
|
+
def get_all_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
121
|
+
# returns a string hash of all flags
|
72
122
|
|
73
|
-
|
74
|
-
|
75
|
-
|
123
|
+
# make sure they're loaded on first run
|
124
|
+
load_feature_flags
|
125
|
+
|
126
|
+
response = {}
|
127
|
+
fallback_to_decide = @feature_flags.empty?
|
128
|
+
|
129
|
+
@feature_flags.each do |flag|
|
130
|
+
begin
|
131
|
+
response[flag[:key]] = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
|
132
|
+
rescue InconclusiveMatchError => e
|
133
|
+
fallback_to_decide = true
|
134
|
+
rescue StandardError => e
|
135
|
+
logger.error "Error computing flag locally: #{e}."
|
136
|
+
fallback_to_decide = true
|
137
|
+
end
|
76
138
|
end
|
139
|
+
|
140
|
+
if fallback_to_decide && !only_evaluate_locally
|
141
|
+
begin
|
142
|
+
flags = get_feature_variants(distinct_id, groups, person_properties, group_properties)
|
143
|
+
response = {**response, **flags}
|
144
|
+
rescue StandardError => e
|
145
|
+
logger.error "Error computing flag remotely: #{e}"
|
146
|
+
end
|
147
|
+
end
|
148
|
+
response
|
77
149
|
end
|
78
150
|
|
79
151
|
def shutdown_poller()
|
80
152
|
@task.shutdown
|
81
153
|
end
|
82
154
|
|
155
|
+
# Class methods
|
156
|
+
|
157
|
+
def self.match_property(property, property_values)
|
158
|
+
# only looks for matches where key exists in property_values
|
159
|
+
# doesn't support operator is_not_set
|
160
|
+
|
161
|
+
PostHog::Utils.symbolize_keys! property
|
162
|
+
PostHog::Utils.symbolize_keys! property_values
|
163
|
+
|
164
|
+
key = property[:key].to_sym
|
165
|
+
value = property[:value]
|
166
|
+
operator = property[:operator] || 'exact'
|
167
|
+
|
168
|
+
if !property_values.key?(key)
|
169
|
+
raise InconclusiveMatchError.new("Property #{key} not found in property_values")
|
170
|
+
elsif operator == 'is_not_set'
|
171
|
+
raise InconclusiveMatchError.new("Operator is_not_set not supported")
|
172
|
+
end
|
173
|
+
|
174
|
+
override_value = property_values[key]
|
175
|
+
|
176
|
+
case operator
|
177
|
+
when 'exact'
|
178
|
+
value.is_a?(Array) ? value.include?(override_value) : value == override_value
|
179
|
+
when 'is_not'
|
180
|
+
value.is_a?(Array) ? !value.include?(override_value) : value != override_value
|
181
|
+
when'is_set'
|
182
|
+
property_values.key?(key)
|
183
|
+
when 'icontains'
|
184
|
+
override_value.to_s.downcase.include?(value.to_s.downcase)
|
185
|
+
when 'not_icontains'
|
186
|
+
!override_value.to_s.downcase.include?(value.to_s.downcase)
|
187
|
+
when 'regex'
|
188
|
+
PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
|
189
|
+
when 'not_regex'
|
190
|
+
PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
|
191
|
+
when 'gt'
|
192
|
+
override_value.class == value.class && override_value > value
|
193
|
+
when 'gte'
|
194
|
+
override_value.class == value.class && override_value >= value
|
195
|
+
when 'lt'
|
196
|
+
override_value.class == value.class && override_value < value
|
197
|
+
when 'lte'
|
198
|
+
override_value.class == value.class && override_value <= value
|
199
|
+
when 'is_date_before', 'is_date_after'
|
200
|
+
parsed_date = PostHog::Utils::convert_to_datetime(value)
|
201
|
+
override_date = PostHog::Utils::convert_to_datetime(override_value)
|
202
|
+
if operator == 'is_date_before'
|
203
|
+
return override_date < parsed_date
|
204
|
+
else
|
205
|
+
return override_date > parsed_date
|
206
|
+
end
|
207
|
+
else
|
208
|
+
logger.error "Unknown operator: #{operator}"
|
209
|
+
false
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
83
213
|
private
|
84
214
|
|
85
|
-
def
|
86
|
-
|
87
|
-
|
88
|
-
@feature_flags = res['results'].filter { |flag| flag['active'] }
|
89
|
-
if @loaded_flags_successfully_once.false?
|
90
|
-
@loaded_flags_successfully_once.make_true
|
215
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
216
|
+
if flag[:ensure_experience_continuity]
|
217
|
+
raise InconclusiveMatchError.new("Flag has experience continuity enabled")
|
91
218
|
end
|
219
|
+
|
220
|
+
return false if !flag[:active]
|
221
|
+
|
222
|
+
flag_filters = flag[:filters] || {}
|
223
|
+
|
224
|
+
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
225
|
+
if !aggregation_group_type_index.nil?
|
226
|
+
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
227
|
+
|
228
|
+
if group_name.nil?
|
229
|
+
logger.warn "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
230
|
+
# failover to `/decide/`
|
231
|
+
raise InconclusiveMatchError.new("Flag has unknown group type index")
|
232
|
+
end
|
233
|
+
|
234
|
+
group_name_symbol = group_name.to_sym
|
235
|
+
|
236
|
+
if !groups.key?(group_name_symbol)
|
237
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
238
|
+
# don't failover to `/decide/`, since response will be the same
|
239
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
240
|
+
return false
|
241
|
+
end
|
242
|
+
|
243
|
+
focused_group_properties = group_properties[group_name_symbol]
|
244
|
+
return match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
|
245
|
+
else
|
246
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties)
|
247
|
+
end
|
248
|
+
|
92
249
|
end
|
93
250
|
|
94
|
-
def
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
251
|
+
def match_feature_flag_properties(flag, distinct_id, properties)
|
252
|
+
flag_filters = flag[:filters] || {}
|
253
|
+
|
254
|
+
flag_conditions = flag_filters[:groups] || []
|
255
|
+
is_inconclusive = false
|
256
|
+
result = nil
|
257
|
+
|
258
|
+
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
259
|
+
# evaluated first, and the variant override is applied to the first matching condition.
|
260
|
+
sorted_flag_conditions = flag_conditions.each_with_index.sort_by { |condition, idx| [condition[:variant].nil? ? 1 : -1, idx] }
|
261
|
+
|
262
|
+
sorted_flag_conditions.each do |condition, idx|
|
263
|
+
begin
|
264
|
+
if is_condition_match(flag, distinct_id, condition, properties)
|
265
|
+
variant_override = condition[:variant]
|
266
|
+
flag_multivariate = flag_filters[:multivariate] || {}
|
267
|
+
flag_variants = flag_multivariate[:variants] || []
|
268
|
+
if flag_variants.map{|variant| variant[:key]}.include?(condition[:variant])
|
269
|
+
variant = variant_override
|
270
|
+
else
|
271
|
+
variant = get_matching_variant(flag, distinct_id)
|
272
|
+
end
|
273
|
+
result = variant || true
|
274
|
+
break
|
275
|
+
end
|
276
|
+
rescue InconclusiveMatchError => e
|
277
|
+
is_inconclusive = true
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
if !result.nil?
|
282
|
+
return result
|
283
|
+
elsif is_inconclusive
|
284
|
+
raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties")
|
285
|
+
end
|
286
|
+
|
287
|
+
# We can only return False when all conditions are False
|
288
|
+
return false
|
289
|
+
end
|
290
|
+
|
291
|
+
def is_condition_match(flag, distinct_id, condition, properties)
|
292
|
+
rollout_percentage = condition[:rollout_percentage]
|
293
|
+
|
294
|
+
if !(condition[:properties] || []).empty?
|
295
|
+
if !condition[:properties].all? { |prop|
|
296
|
+
FeatureFlagsPoller.match_property(prop, properties)
|
297
|
+
}
|
298
|
+
return false
|
299
|
+
elsif rollout_percentage.nil?
|
300
|
+
return true
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
if !rollout_percentage.nil? and _hash(flag[:key], distinct_id) > (rollout_percentage.to_f/100)
|
305
|
+
return false
|
306
|
+
end
|
307
|
+
|
308
|
+
return true
|
309
|
+
end
|
310
|
+
|
311
|
+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
312
|
+
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
313
|
+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
314
|
+
# we can do _hash(key, distinct_id) < 0.2
|
315
|
+
def _hash(key, distinct_id, salt="")
|
316
|
+
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
317
|
+
return (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
318
|
+
end
|
319
|
+
|
320
|
+
def get_matching_variant(flag, distinct_id)
|
321
|
+
hash_value = _hash(flag[:key], distinct_id, salt="variant")
|
322
|
+
matching_variant = variant_lookup_table(flag).find { |variant|
|
323
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
324
|
+
}
|
325
|
+
matching_variant.nil? ? nil : matching_variant[:key]
|
326
|
+
end
|
327
|
+
|
328
|
+
def variant_lookup_table(flag)
|
329
|
+
lookup_table = []
|
330
|
+
value_min = 0
|
331
|
+
flag_filters = flag[:filters] || {}
|
332
|
+
variants = flag_filters[:multivariate] || {}
|
333
|
+
multivariates = variants[:variants] || []
|
334
|
+
multivariates.each do |variant|
|
335
|
+
value_max = value_min + variant[:rollout_percentage].to_f / 100
|
336
|
+
lookup_table << {'value_min': value_min, 'value_max': value_max, 'key': variant[:key]}
|
337
|
+
value_min = value_max
|
338
|
+
end
|
339
|
+
return lookup_table
|
340
|
+
end
|
341
|
+
|
342
|
+
def _load_feature_flags()
|
343
|
+
res = _request_feature_flag_definitions
|
344
|
+
@feature_flags.clear
|
345
|
+
|
346
|
+
if !res.key?(:flags)
|
347
|
+
logger.error "Failed to load feature flags: #{res}"
|
100
348
|
else
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
349
|
+
@feature_flags = res[:flags] || []
|
350
|
+
@group_type_mapping = res[:group_type_mapping] || {}
|
351
|
+
|
352
|
+
logger.debug "Loaded #{@feature_flags.length} feature flags"
|
353
|
+
if @loaded_flags_successfully_once.false?
|
354
|
+
@loaded_flags_successfully_once.make_true
|
355
|
+
end
|
105
356
|
end
|
357
|
+
end
|
358
|
+
|
359
|
+
def _request_feature_flag_definitions
|
360
|
+
uri = URI("#{@host}/api/feature_flag/local_evaluation?token=#{@project_api_key}")
|
361
|
+
req = Net::HTTP::Get.new(uri)
|
362
|
+
req['Authorization'] = "Bearer #{@personal_api_key}"
|
363
|
+
|
364
|
+
_request(uri, req)
|
365
|
+
end
|
106
366
|
|
107
|
-
|
367
|
+
def _request_feature_flag_evaluation(data={})
|
368
|
+
uri = URI("#{@host}/decide/?v=2")
|
369
|
+
req = Net::HTTP::Post.new(uri)
|
370
|
+
req['Content-Type'] = 'application/json'
|
371
|
+
data['token'] = @project_api_key
|
372
|
+
req.body = data.to_json
|
373
|
+
|
374
|
+
_request(uri, req)
|
375
|
+
end
|
376
|
+
|
377
|
+
def _request(uri, request_object)
|
378
|
+
|
379
|
+
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
|
108
380
|
|
109
381
|
begin
|
110
382
|
res_body = nil
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
return res_body
|
116
|
-
end
|
383
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
384
|
+
res = http.request(request_object)
|
385
|
+
JSON.parse(res.body, {symbolize_names: true})
|
386
|
+
end
|
117
387
|
rescue Timeout::Error,
|
118
388
|
Errno::EINVAL,
|
119
389
|
Errno::ECONNRESET,
|
@@ -124,6 +394,6 @@ class PostHog
|
|
124
394
|
logger.debug("Unable to complete request to #{uri}")
|
125
395
|
throw e
|
126
396
|
end
|
127
|
-
end
|
397
|
+
end
|
128
398
|
end
|
129
399
|
end
|
data/lib/posthog/field_parser.rb
CHANGED
@@ -7,15 +7,22 @@ class PostHog
|
|
7
7
|
#
|
8
8
|
# - "event"
|
9
9
|
# - "properties"
|
10
|
+
# - "groups"
|
10
11
|
def parse_for_capture(fields)
|
11
12
|
common = parse_common_fields(fields)
|
12
13
|
|
13
14
|
event = fields[:event]
|
14
15
|
properties = fields[:properties] || {}
|
16
|
+
groups = fields[:groups]
|
15
17
|
|
16
18
|
check_presence!(event, 'event')
|
17
19
|
check_is_hash!(properties, 'properties')
|
18
20
|
|
21
|
+
if groups
|
22
|
+
check_is_hash!(groups, 'groups')
|
23
|
+
properties["$groups"] = groups
|
24
|
+
end
|
25
|
+
|
19
26
|
isoify_dates! properties
|
20
27
|
|
21
28
|
common.merge(
|
@@ -48,6 +55,33 @@ class PostHog
|
|
48
55
|
)
|
49
56
|
end
|
50
57
|
|
58
|
+
def parse_for_group_identify(fields)
|
59
|
+
properties = fields[:properties] || {}
|
60
|
+
group_type = fields[:group_type]
|
61
|
+
group_key = fields[:group_key]
|
62
|
+
|
63
|
+
check_presence!(group_type, 'group type')
|
64
|
+
check_presence!(group_key, 'group_key')
|
65
|
+
check_is_hash!(properties, 'properties')
|
66
|
+
|
67
|
+
distinct_id = "$#{group_type}_#{group_key}"
|
68
|
+
fields[:distinct_id] = distinct_id
|
69
|
+
common = parse_common_fields(fields)
|
70
|
+
|
71
|
+
isoify_dates! properties
|
72
|
+
|
73
|
+
common.merge(
|
74
|
+
{
|
75
|
+
event: '$groupidentify',
|
76
|
+
properties: {
|
77
|
+
"$group_type": group_type,
|
78
|
+
"$group_key": group_key,
|
79
|
+
"$group_set": properties.merge(common[:properties] || {})
|
80
|
+
},
|
81
|
+
}
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
51
85
|
# In addition to the common fields, alias accepts:
|
52
86
|
#
|
53
87
|
# - "alias"
|
@@ -79,10 +113,12 @@ class PostHog
|
|
79
113
|
# - "timestamp"
|
80
114
|
# - "distinct_id"
|
81
115
|
# - "message_id"
|
116
|
+
# - "send_feature_flags"
|
82
117
|
def parse_common_fields(fields)
|
83
118
|
timestamp = fields[:timestamp] || Time.new
|
84
119
|
distinct_id = fields[:distinct_id]
|
85
120
|
message_id = fields[:message_id].to_s if fields[:message_id]
|
121
|
+
send_feature_flags = fields[:send_feature_flags]
|
86
122
|
|
87
123
|
check_timestamp! timestamp
|
88
124
|
check_presence! distinct_id, 'distinct_id'
|
@@ -98,6 +134,14 @@ class PostHog
|
|
98
134
|
'$lib_version' => PostHog::VERSION.to_s
|
99
135
|
}
|
100
136
|
}
|
137
|
+
|
138
|
+
if send_feature_flags
|
139
|
+
feature_variants = fields[:feature_variants]
|
140
|
+
feature_variants.each do |key, value|
|
141
|
+
parsed[:properties]["$feature/#{key}"] = value
|
142
|
+
end
|
143
|
+
parsed[:properties]["$active_feature_flags"] = feature_variants.keys
|
144
|
+
end
|
101
145
|
parsed
|
102
146
|
end
|
103
147
|
|
data/lib/posthog/logging.rb
CHANGED
@@ -23,6 +23,14 @@ class PostHog
|
|
23
23
|
def error(msg)
|
24
24
|
@logger.error("#{@prefix} #{msg}")
|
25
25
|
end
|
26
|
+
|
27
|
+
def level=(severity)
|
28
|
+
@logger.level = severity
|
29
|
+
end
|
30
|
+
|
31
|
+
def level
|
32
|
+
@logger.level
|
33
|
+
end
|
26
34
|
end
|
27
35
|
|
28
36
|
module Logging
|
@@ -36,6 +44,7 @@ class PostHog
|
|
36
44
|
else
|
37
45
|
logger = Logger.new STDOUT
|
38
46
|
logger.progname = 'PostHog'
|
47
|
+
logger.level = Logger::WARN
|
39
48
|
logger
|
40
49
|
end
|
41
50
|
@logger = PrefixedLogger.new(base_logger, '[posthog-ruby]')
|
data/lib/posthog/send_worker.rb
CHANGED
@@ -28,7 +28,7 @@ class PostHog
|
|
28
28
|
batch_size = options[:batch_size] || Defaults::MessageBatch::MAX_SIZE
|
29
29
|
@batch = MessageBatch.new(batch_size)
|
30
30
|
@lock = Mutex.new
|
31
|
-
@transport = Transport.new api_host: options[:
|
31
|
+
@transport = Transport.new api_host: options[:host], skip_ssl_verification: options[:skip_ssl_verification]
|
32
32
|
end
|
33
33
|
|
34
34
|
# public: Continuously runs the loop to check for new events
|
data/lib/posthog/transport.rb
CHANGED
@@ -20,9 +20,11 @@ class PostHog
|
|
20
20
|
options[:ssl] = uri.scheme == 'https'
|
21
21
|
options[:port] = uri.port
|
22
22
|
end
|
23
|
-
|
24
|
-
options[:
|
25
|
-
options[:
|
23
|
+
|
24
|
+
options[:host] = !options[:host].nil? ? options[:host] : HOST
|
25
|
+
options[:port] = !options[:port].nil? ? options[:port] : PORT
|
26
|
+
options[:ssl] = !options[:ssl].nil? ? options[:ssl] : SSL
|
27
|
+
|
26
28
|
@headers = options[:headers] || HEADERS
|
27
29
|
@path = options[:path] || PATH
|
28
30
|
@retries = options[:retries] || RETRIES
|
data/lib/posthog/utils.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
require 'securerandom'
|
2
2
|
|
3
3
|
class PostHog
|
4
|
+
|
5
|
+
class InconclusiveMatchError < StandardError
|
6
|
+
end
|
7
|
+
|
4
8
|
module Utils
|
5
9
|
extend self
|
6
10
|
|
@@ -83,7 +87,46 @@ class PostHog
|
|
83
87
|
]
|
84
88
|
end
|
85
89
|
|
90
|
+
def convert_to_datetime(value)
|
91
|
+
if value.respond_to?(:strftime)
|
92
|
+
parsed_date = value
|
93
|
+
return parsed_date
|
94
|
+
elsif value.respond_to?(:to_str)
|
95
|
+
begin
|
96
|
+
parsed_date = DateTime.parse(value)
|
97
|
+
return parsed_date
|
98
|
+
rescue ArgumentError => e
|
99
|
+
raise InconclusiveMatchError.new("#{value} is not in a valid date format")
|
100
|
+
end
|
101
|
+
else
|
102
|
+
raise InconclusiveMatchError.new("The date provided must be a string or date object")
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
86
106
|
UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
|
87
107
|
UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
|
108
|
+
|
109
|
+
def is_valid_regex(regex)
|
110
|
+
begin
|
111
|
+
Regexp.new(regex)
|
112
|
+
return true
|
113
|
+
rescue RegexpError
|
114
|
+
return false
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
class SizeLimitedHash < Hash
|
119
|
+
def initialize(max_length, *args, &block)
|
120
|
+
super(*args, &block)
|
121
|
+
@max_length = max_length
|
122
|
+
end
|
123
|
+
|
124
|
+
def []=(key, value)
|
125
|
+
if length >= @max_length
|
126
|
+
clear
|
127
|
+
end
|
128
|
+
super
|
129
|
+
end
|
130
|
+
end
|
88
131
|
end
|
89
132
|
end
|
data/lib/posthog/version.rb
CHANGED
metadata
CHANGED
@@ -1,29 +1,35 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: posthog-ruby
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 2.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- ''
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-11-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1'
|
20
|
+
- - "<"
|
18
21
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
22
|
+
version: 1.1.10
|
20
23
|
type: :runtime
|
21
24
|
prerelease: false
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
23
26
|
requirements:
|
24
|
-
- - "
|
27
|
+
- - "~>"
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '1'
|
30
|
+
- - "<"
|
25
31
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
32
|
+
version: 1.1.10
|
27
33
|
- !ruby/object:Gem::Dependency
|
28
34
|
name: commander
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -163,7 +169,7 @@ homepage: https://github.com/PostHog/posthog-ruby
|
|
163
169
|
licenses:
|
164
170
|
- MIT
|
165
171
|
metadata: {}
|
166
|
-
post_install_message:
|
172
|
+
post_install_message:
|
167
173
|
rdoc_options: []
|
168
174
|
require_paths:
|
169
175
|
- lib
|
@@ -178,8 +184,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
178
184
|
- !ruby/object:Gem::Version
|
179
185
|
version: '0'
|
180
186
|
requirements: []
|
181
|
-
rubygems_version: 3.1.
|
182
|
-
signing_key:
|
187
|
+
rubygems_version: 3.1.2
|
188
|
+
signing_key:
|
183
189
|
specification_version: 4
|
184
190
|
summary: PostHog library
|
185
191
|
test_files: []
|