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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b90a7b41e6a5a6fbbf48f555b9948e05b1d4caec377ca715a07701e5d655123
4
- data.tar.gz: cab835193f7c6ecba36d5121201ef5c216b9bdf31762124811a1fa43754c29e1
3
+ metadata.gz: 0f9f2eb496ae894a6481be1072bdea6c38dbbc7f115e433994a559efd4f906e8
4
+ data.tar.gz: 71af8b9c90bb49b9d8816a4eb579a38a93dc4886da74827c0548ff2b4e206206
5
5
  SHA512:
6
- metadata.gz: bdee6bf1dc34a46de738bfa5c73386d6054677080a25ab79ea37311c01992963b4731a432e2705d96e6fc10f66fed21f8a0438d4b8be3117c866fb4dd622f3b4
7
- data.tar.gz: 91f6a9b65ebbcd9d95536f8542b96b777d2a47ece861912331d5764973101c77818f6104bc1b857f4f458883fe159ed46bd8029eb6076de541705b15f11c70a7
6
+ metadata.gz: a15603ba1c590a123d0cc086343c77e4b9239a2fea94ed7c99f37756f869c27e3b07705fd9e45c3cce75e4a6a64ae79fd1c26e9c1515ceeacb172ef214ee2800
7
+ data.tar.gz: be40d32a621aea85c184c39499dff819eeec6a51d953c5f5dba4446262811c97b3bd5e96b28c3767f01588da2ba207a9a82fe678df9fb915fe701d4671357620
@@ -4,7 +4,8 @@ require 'time'
4
4
  require 'posthog/defaults'
5
5
  require 'posthog/logging'
6
6
  require 'posthog/utils'
7
- require 'posthog/worker'
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 = Worker.new(@queue, @api_key, opts)
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 = nil
41
+ @personal_api_key = opts[:personal_api_key]
31
42
 
32
43
  check_api_key!
33
44
 
34
- if opts[:personal_api_key]
35
- @personal_api_key = opts[:personal_api_key]
36
- @feature_flags_poller =
37
- FeatureFlagsPoller.new(
38
- opts[:feature_flags_polling_interval],
39
- opts[:personal_api_key],
40
- @api_key,
41
- opts[:host]
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 flushed the queue.
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, default_value = false)
105
- unless @personal_api_key
106
- logger.error(
107
- 'You need to specify a personal_api_key to use feature flags'
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
- is_enabled =
112
- @feature_flags_poller.is_feature_enabled(
113
- flag_key,
114
- distinct_id,
115
- default_value
116
- )
117
- capture(
118
- {
119
- 'distinct_id': distinct_id,
120
- 'event': '$feature_flag_called',
121
- 'properties': {
122
- '$feature_flag': flag_key,
123
- '$feature_flag_response': is_enabled
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
- return is_enabled
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 use feature flags'
211
+ 'You need to specify a personal_api_key to locally evaluate feature flags'
134
212
  )
135
213
  return
136
214
  end
@@ -1,5 +1,8 @@
1
1
  class PostHog
2
2
  module Defaults
3
+
4
+ MAX_HASH_SIZE = 50_000
5
+
3
6
  module Request
4
7
  HOST = 'app.posthog.com'
5
8
  PORT = 443
@@ -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 || 60 * 5
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 || 'app.posthog.com'
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
- # load once before timer
26
- load_feature_flags
27
- @task.execute
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 is_feature_enabled(key, distinct_id, default_result = false)
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
- return default_result unless @loaded_flags_successfully_once
73
+ symbolize_keys! groups
74
+ symbolize_keys! person_properties
75
+ symbolize_keys! group_properties
35
76
 
36
- feature_flag = nil
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['key']
85
+ if key == flag[:key]
41
86
  feature_flag = flag
42
87
  break
43
88
  end
44
89
  end
45
90
 
46
- return default_result if !feature_flag
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
- flag_rollout_pctg =
49
- if feature_flag['rollout_percentage']
50
- feature_flag['rollout_percentage']
51
- else
52
- 100
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
- return false
117
+ [response, flag_was_locally_evaluated]
63
118
  end
64
119
 
65
- def is_simple_flag_enabled(key, distinct_id, rollout_percentage)
66
- hash = Digest::SHA1.hexdigest "#{key}.#{distinct_id}"
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
- def load_feature_flags(force_reload = false)
74
- if @loaded_flags_successfully_once.false? || force_reload
75
- _load_feature_flags
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 _load_feature_flags()
86
- res = _request('GET', 'api/feature_flag', true)
87
- @feature_flags.clear
88
- @feature_flags = res['results'].filter { |flag| flag['active'] }
89
- if @loaded_flags_successfully_once.false?
90
- @loaded_flags_successfully_once.make_true
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 _request(method, endpoint, use_personal_api_key = false, data = {})
95
- uri = URI("https://#{@host}/#{endpoint}/?token=#{@project_api_key}")
96
- req = nil
97
- if use_personal_api_key
98
- req = Net::HTTP::Get.new(uri)
99
- req['Authorization'] = "Bearer #{@personal_api_key}"
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
- req = Net::HTTP::Post.new(uri)
102
- req['Content-Type'] = 'application/json'
103
- data['token'] = @project_api_key
104
- req.body = data.to_json
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
- req['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
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
- res =
112
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
113
- res = http.request(req)
114
- res_body = JSON.parse(res.body)
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
@@ -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: nil,
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
 
@@ -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]')
@@ -0,0 +1,16 @@
1
+ # A worker that doesn't consume jobs
2
+ class PostHog
3
+ class NoopWorker
4
+ def initialize(queue)
5
+ @queue = queue
6
+ end
7
+
8
+ def run
9
+ # Does nothing
10
+ end
11
+
12
+ def is_requesting?
13
+ false
14
+ end
15
+ end
16
+ end
@@ -4,7 +4,7 @@ require 'posthog/transport'
4
4
  require 'posthog/utils'
5
5
 
6
6
  class PostHog
7
- class Worker
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[:api_host], skip_ssl_verification: options[:skip_ssl_verification]
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
@@ -20,9 +20,11 @@ class PostHog
20
20
  options[:ssl] = uri.scheme == 'https'
21
21
  options[:port] = uri.port
22
22
  end
23
- options[:host] ||= HOST
24
- options[:port] ||= PORT
25
- options[:ssl] ||= SSL
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
@@ -1,3 +1,3 @@
1
1
  class PostHog
2
- VERSION = '1.2.3'
2
+ VERSION = '2.0.0'
3
3
  end
data/lib/posthog.rb CHANGED
@@ -3,7 +3,7 @@ require 'posthog/defaults'
3
3
  require 'posthog/utils'
4
4
  require 'posthog/field_parser'
5
5
  require 'posthog/client'
6
- require 'posthog/worker'
6
+ require 'posthog/send_worker'
7
7
  require 'posthog/transport'
8
8
  require 'posthog/response'
9
9
  require 'posthog/logging'
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.2.3
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-01-18 00:00:00.000000000 Z
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: '0'
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: '0'
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.0.3.1
187
+ rubygems_version: 3.1.2
181
188
  signing_key:
182
189
  specification_version: 4
183
190
  summary: PostHog library