posthog-ruby 1.2.3 → 2.0.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 +116 -38
- data/lib/posthog/defaults.rb +3 -0
- data/lib/posthog/feature_flags.rb +307 -57
- data/lib/posthog/field_parser.rb +46 -2
- data/lib/posthog/logging.rb +9 -0
- data/lib/posthog/noop_worker.rb +16 -0
- data/lib/posthog/{worker.rb → send_worker.rb} +2 -2
- data/lib/posthog/transport.rb +5 -3
- data/lib/posthog/utils.rb +23 -0
- data/lib/posthog/version.rb +1 -1
- data/lib/posthog.rb +1 -1
- metadata +15 -8
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
@@ -4,7 +4,8 @@ require 'time'
|
|
4
4
|
require 'posthog/defaults'
|
5
5
|
require 'posthog/logging'
|
6
6
|
require 'posthog/utils'
|
7
|
-
require 'posthog/
|
7
|
+
require 'posthog/send_worker'
|
8
|
+
require 'posthog/noop_worker'
|
8
9
|
require 'posthog/feature_flags'
|
9
10
|
|
10
11
|
class PostHog
|
@@ -15,37 +16,46 @@ class PostHog
|
|
15
16
|
# @param [Hash] opts
|
16
17
|
# @option opts [String] :api_key Your project's api_key
|
17
18
|
# @option opts [FixNum] :max_queue_size Maximum number of calls to be
|
18
|
-
# remain queued.
|
19
|
+
# remain queued. Defaults to 10_000.
|
20
|
+
# @option opts [Bool] :test_mode +true+ if messages should remain
|
21
|
+
# queued for testing. Defaults to +false+.
|
19
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.
|
20
25
|
def initialize(opts = {})
|
21
26
|
symbolize_keys!(opts)
|
22
27
|
|
28
|
+
opts[:host] ||= 'https://app.posthog.com'
|
29
|
+
|
23
30
|
@queue = Queue.new
|
24
31
|
@api_key = opts[:api_key]
|
25
32
|
@max_queue_size = opts[:max_queue_size] || Defaults::Queue::MAX_SIZE
|
26
33
|
@worker_mutex = Mutex.new
|
27
|
-
@worker =
|
34
|
+
@worker = if opts[:test_mode]
|
35
|
+
NoopWorker.new(@queue)
|
36
|
+
else
|
37
|
+
SendWorker.new(@queue, @api_key, opts)
|
38
|
+
end
|
28
39
|
@worker_thread = nil
|
29
40
|
@feature_flags_poller = nil
|
30
|
-
@personal_api_key =
|
41
|
+
@personal_api_key = opts[:personal_api_key]
|
31
42
|
|
32
43
|
check_api_key!
|
33
44
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
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 }
|
44
54
|
|
45
55
|
at_exit { @worker_thread && @worker_thread[:should_exit] = true }
|
46
56
|
end
|
47
57
|
|
48
|
-
# Synchronously waits until the worker has
|
58
|
+
# Synchronously waits until the worker has cleared the queue.
|
49
59
|
#
|
50
60
|
# Use only for scripts which are not long-running, and will specifically
|
51
61
|
# exit
|
@@ -56,6 +66,13 @@ class PostHog
|
|
56
66
|
end
|
57
67
|
end
|
58
68
|
|
69
|
+
# Clears the queue without waiting.
|
70
|
+
#
|
71
|
+
# Use only in test mode
|
72
|
+
def clear
|
73
|
+
@queue.clear
|
74
|
+
end
|
75
|
+
|
59
76
|
# @!macro common_attrs
|
60
77
|
# @option attrs [String] :message_id ID that uniquely
|
61
78
|
# identifies a message across the API. (optional)
|
@@ -68,9 +85,16 @@ class PostHog
|
|
68
85
|
#
|
69
86
|
# @option attrs [String] :event Event name
|
70
87
|
# @option attrs [Hash] :properties Event properties (optional)
|
88
|
+
# @option attrs [Bool] :send_feature_flags Whether to send feature flags with this event (optional)
|
71
89
|
# @macro common_attrs
|
72
90
|
def capture(attrs)
|
73
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
|
+
|
74
98
|
enqueue(FieldParser.parse_for_capture(attrs))
|
75
99
|
end
|
76
100
|
|
@@ -85,6 +109,19 @@ class PostHog
|
|
85
109
|
enqueue(FieldParser.parse_for_identify(attrs))
|
86
110
|
end
|
87
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
|
+
|
88
125
|
# Aliases a user from one id to another
|
89
126
|
#
|
90
127
|
# @param [Hash] attrs
|
@@ -96,41 +133,82 @@ class PostHog
|
|
96
133
|
enqueue(FieldParser.parse_for_alias(attrs))
|
97
134
|
end
|
98
135
|
|
136
|
+
# @return [Hash] pops the last message from the queue
|
137
|
+
def dequeue_last_message
|
138
|
+
@queue.pop
|
139
|
+
end
|
140
|
+
|
99
141
|
# @return [Fixnum] number of messages in the queue
|
100
142
|
def queued_messages
|
101
143
|
@queue.length
|
102
144
|
end
|
103
145
|
|
104
|
-
def is_feature_enabled(flag_key, distinct_id,
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
)
|
109
|
-
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
|
110
150
|
end
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
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,
|
124
189
|
}
|
125
|
-
|
126
|
-
|
127
|
-
|
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)
|
128
206
|
end
|
129
207
|
|
130
208
|
def reload_feature_flags
|
131
209
|
unless @personal_api_key
|
132
210
|
logger.error(
|
133
|
-
'You need to specify a personal_api_key to
|
211
|
+
'You need to specify a personal_api_key to locally evaluate feature flags'
|
134
212
|
)
|
135
213
|
return
|
136
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,13 +55,40 @@ 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"
|
54
88
|
def parse_for_alias(fields)
|
55
89
|
common = parse_common_fields(fields)
|
56
90
|
|
57
|
-
distinct_id = common[:distinct_id] # must move to properties
|
91
|
+
distinct_id = common[:distinct_id] # must both be set and move to properties
|
58
92
|
|
59
93
|
alias_field = fields[:alias]
|
60
94
|
check_presence! alias_field, 'alias'
|
@@ -63,7 +97,7 @@ class PostHog
|
|
63
97
|
{
|
64
98
|
type: 'alias',
|
65
99
|
event: '$create_alias',
|
66
|
-
distinct_id:
|
100
|
+
distinct_id: distinct_id,
|
67
101
|
properties:
|
68
102
|
{ distinct_id: distinct_id, alias: alias_field }.merge(
|
69
103
|
common[:properties] || {}
|
@@ -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]')
|
@@ -4,7 +4,7 @@ require 'posthog/transport'
|
|
4
4
|
require 'posthog/utils'
|
5
5
|
|
6
6
|
class PostHog
|
7
|
-
class
|
7
|
+
class SendWorker
|
8
8
|
include PostHog::Utils
|
9
9
|
include PostHog::Defaults
|
10
10
|
include PostHog::Logging
|
@@ -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
data/lib/posthog.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
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
|
@@ -153,11 +159,12 @@ files:
|
|
153
159
|
- lib/posthog/field_parser.rb
|
154
160
|
- lib/posthog/logging.rb
|
155
161
|
- lib/posthog/message_batch.rb
|
162
|
+
- lib/posthog/noop_worker.rb
|
156
163
|
- lib/posthog/response.rb
|
164
|
+
- lib/posthog/send_worker.rb
|
157
165
|
- lib/posthog/transport.rb
|
158
166
|
- lib/posthog/utils.rb
|
159
167
|
- lib/posthog/version.rb
|
160
|
-
- lib/posthog/worker.rb
|
161
168
|
homepage: https://github.com/PostHog/posthog-ruby
|
162
169
|
licenses:
|
163
170
|
- MIT
|
@@ -177,7 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
177
184
|
- !ruby/object:Gem::Version
|
178
185
|
version: '0'
|
179
186
|
requirements: []
|
180
|
-
rubygems_version: 3.
|
187
|
+
rubygems_version: 3.1.2
|
181
188
|
signing_key:
|
182
189
|
specification_version: 4
|
183
190
|
summary: PostHog library
|