posthog-ruby 1.3.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/posthog/client.rb +93 -34
- data/lib/posthog/defaults.rb +3 -0
- data/lib/posthog/feature_flags.rb +307 -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 +23 -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: 0f9f2eb496ae894a6481be1072bdea6c38dbbc7f115e433994a559efd4f906e8
|
4
|
+
data.tar.gz: 71af8b9c90bb49b9d8816a4eb579a38a93dc4886da74827c0548ff2b4e206206
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a15603ba1c590a123d0cc086343c77e4b9239a2fea94ed7c99f37756f869c27e3b07705fd9e45c3cce75e4a6a64ae79fd1c26e9c1515ceeacb172ef214ee2800
|
7
|
+
data.tar.gz: be40d32a621aea85c184c39499dff819eeec6a51d953c5f5dba4446262811c97b3bd5e96b28c3767f01588da2ba207a9a82fe678df9fb915fe701d4671357620
|
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,366 @@ 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
|
43
|
+
end
|
44
|
+
|
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
|
28
67
|
end
|
29
68
|
|
30
|
-
def
|
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
|
101
|
+
|
102
|
+
flag_was_locally_evaluated = !response.nil?
|
47
103
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
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
|
+
else
|
200
|
+
logger.error "Unknown operator: #{operator}"
|
201
|
+
false
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
83
205
|
private
|
84
206
|
|
85
|
-
def
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
207
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
208
|
+
if flag[:ensure_experience_continuity]
|
209
|
+
raise InconclusiveMatchError.new("Flag has experience continuity enabled")
|
210
|
+
end
|
211
|
+
|
212
|
+
return false if !flag[:active]
|
213
|
+
|
214
|
+
flag_filters = flag[:filters] || {}
|
215
|
+
|
216
|
+
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
217
|
+
if !aggregation_group_type_index.nil?
|
218
|
+
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
219
|
+
|
220
|
+
if group_name.nil?
|
221
|
+
logger.warn "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
222
|
+
# failover to `/decide/`
|
223
|
+
raise InconclusiveMatchError.new("Flag has unknown group type index")
|
224
|
+
end
|
225
|
+
|
226
|
+
group_name_symbol = group_name.to_sym
|
227
|
+
|
228
|
+
if !groups.key?(group_name_symbol)
|
229
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
230
|
+
# don't failover to `/decide/`, since response will be the same
|
231
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
232
|
+
return false
|
233
|
+
end
|
234
|
+
|
235
|
+
focused_group_properties = group_properties[group_name_symbol]
|
236
|
+
return match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
|
237
|
+
else
|
238
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties)
|
239
|
+
end
|
240
|
+
|
241
|
+
end
|
242
|
+
|
243
|
+
def match_feature_flag_properties(flag, distinct_id, properties)
|
244
|
+
flag_filters = flag[:filters] || {}
|
245
|
+
|
246
|
+
flag_conditions = flag_filters[:groups] || []
|
247
|
+
is_inconclusive = false
|
248
|
+
result = nil
|
249
|
+
|
250
|
+
flag_conditions.each do |condition|
|
251
|
+
begin
|
252
|
+
if is_condition_match(flag, distinct_id, condition, properties)
|
253
|
+
result = get_matching_variant(flag, distinct_id) || true
|
254
|
+
break
|
255
|
+
end
|
256
|
+
rescue InconclusiveMatchError => e
|
257
|
+
is_inconclusive = true
|
258
|
+
end
|
91
259
|
end
|
260
|
+
|
261
|
+
if !result.nil?
|
262
|
+
return result
|
263
|
+
elsif is_inconclusive
|
264
|
+
raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties")
|
265
|
+
end
|
266
|
+
|
267
|
+
# We can only return False when all conditions are False
|
268
|
+
return false
|
92
269
|
end
|
93
270
|
|
94
|
-
def
|
95
|
-
|
96
|
-
|
97
|
-
if
|
98
|
-
|
99
|
-
|
271
|
+
def is_condition_match(flag, distinct_id, condition, properties)
|
272
|
+
rollout_percentage = condition[:rollout_percentage]
|
273
|
+
|
274
|
+
if !(condition[:properties] || []).empty?
|
275
|
+
if !condition[:properties].all? { |prop|
|
276
|
+
FeatureFlagsPoller.match_property(prop, properties)
|
277
|
+
}
|
278
|
+
return false
|
279
|
+
elsif rollout_percentage.nil?
|
280
|
+
return true
|
281
|
+
end
|
282
|
+
end
|
283
|
+
|
284
|
+
if !rollout_percentage.nil? and _hash(flag[:key], distinct_id) > (rollout_percentage.to_f/100)
|
285
|
+
return false
|
286
|
+
end
|
287
|
+
|
288
|
+
return true
|
289
|
+
end
|
290
|
+
|
291
|
+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
292
|
+
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
293
|
+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
294
|
+
# we can do _hash(key, distinct_id) < 0.2
|
295
|
+
def _hash(key, distinct_id, salt="")
|
296
|
+
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
297
|
+
return (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
298
|
+
end
|
299
|
+
|
300
|
+
def get_matching_variant(flag, distinct_id)
|
301
|
+
hash_value = _hash(flag[:key], distinct_id, salt="variant")
|
302
|
+
matching_variant = variant_lookup_table(flag).find { |variant|
|
303
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
304
|
+
}
|
305
|
+
matching_variant.nil? ? nil : matching_variant[:key]
|
306
|
+
end
|
307
|
+
|
308
|
+
def variant_lookup_table(flag)
|
309
|
+
lookup_table = []
|
310
|
+
value_min = 0
|
311
|
+
flag_filters = flag[:filters] || {}
|
312
|
+
variants = flag_filters[:multivariate] || {}
|
313
|
+
multivariates = variants[:variants] || []
|
314
|
+
multivariates.each do |variant|
|
315
|
+
value_max = value_min + variant[:rollout_percentage].to_f / 100
|
316
|
+
lookup_table << {'value_min': value_min, 'value_max': value_max, 'key': variant[:key]}
|
317
|
+
value_min = value_max
|
318
|
+
end
|
319
|
+
return lookup_table
|
320
|
+
end
|
321
|
+
|
322
|
+
def _load_feature_flags()
|
323
|
+
res = _request_feature_flag_definitions
|
324
|
+
@feature_flags.clear
|
325
|
+
|
326
|
+
if !res.key?(:flags)
|
327
|
+
logger.error "Failed to load feature flags: #{res}"
|
100
328
|
else
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
329
|
+
@feature_flags = res[:flags] || []
|
330
|
+
@group_type_mapping = res[:group_type_mapping] || {}
|
331
|
+
|
332
|
+
logger.debug "Loaded #{@feature_flags.length} feature flags"
|
333
|
+
if @loaded_flags_successfully_once.false?
|
334
|
+
@loaded_flags_successfully_once.make_true
|
335
|
+
end
|
105
336
|
end
|
337
|
+
end
|
106
338
|
|
107
|
-
|
339
|
+
def _request_feature_flag_definitions
|
340
|
+
uri = URI("#{@host}/api/feature_flag/local_evaluation?token=#{@project_api_key}")
|
341
|
+
req = Net::HTTP::Get.new(uri)
|
342
|
+
req['Authorization'] = "Bearer #{@personal_api_key}"
|
343
|
+
|
344
|
+
_request(uri, req)
|
345
|
+
end
|
346
|
+
|
347
|
+
def _request_feature_flag_evaluation(data={})
|
348
|
+
uri = URI("#{@host}/decide/?v=2")
|
349
|
+
req = Net::HTTP::Post.new(uri)
|
350
|
+
req['Content-Type'] = 'application/json'
|
351
|
+
data['token'] = @project_api_key
|
352
|
+
req.body = data.to_json
|
353
|
+
|
354
|
+
_request(uri, req)
|
355
|
+
end
|
356
|
+
|
357
|
+
def _request(uri, request_object)
|
358
|
+
|
359
|
+
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
|
108
360
|
|
109
361
|
begin
|
110
362
|
res_body = nil
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
return res_body
|
116
|
-
end
|
363
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
364
|
+
res = http.request(request_object)
|
365
|
+
JSON.parse(res.body, {symbolize_names: true})
|
366
|
+
end
|
117
367
|
rescue Timeout::Error,
|
118
368
|
Errno::EINVAL,
|
119
369
|
Errno::ECONNRESET,
|
@@ -124,6 +374,6 @@ class PostHog
|
|
124
374
|
logger.debug("Unable to complete request to #{uri}")
|
125
375
|
throw e
|
126
376
|
end
|
127
|
-
end
|
377
|
+
end
|
128
378
|
end
|
129
379
|
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
@@ -85,5 +85,28 @@ class PostHog
|
|
85
85
|
|
86
86
|
UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
|
87
87
|
UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
|
88
|
+
|
89
|
+
def is_valid_regex(regex)
|
90
|
+
begin
|
91
|
+
Regexp.new(regex)
|
92
|
+
return true
|
93
|
+
rescue RegexpError
|
94
|
+
return false
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
class SizeLimitedHash < Hash
|
99
|
+
def initialize(max_length, *args, &block)
|
100
|
+
super(*args, &block)
|
101
|
+
@max_length = max_length
|
102
|
+
end
|
103
|
+
|
104
|
+
def []=(key, value)
|
105
|
+
if length >= @max_length
|
106
|
+
clear
|
107
|
+
end
|
108
|
+
super
|
109
|
+
end
|
110
|
+
end
|
88
111
|
end
|
89
112
|
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:
|
4
|
+
version: 2.0.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-08-12 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: []
|