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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cc2c1e12c53fc80013ff8d49d082d1d35ad625fa0db972e53afbc205f595664
4
- data.tar.gz: f95c1cb5502e0ae2d58e10b0575065fb797bbccd73d96098249a50ab6f60ee0a
3
+ metadata.gz: 0f9f2eb496ae894a6481be1072bdea6c38dbbc7f115e433994a559efd4f906e8
4
+ data.tar.gz: 71af8b9c90bb49b9d8816a4eb579a38a93dc4886da74827c0548ff2b4e206206
5
5
  SHA512:
6
- metadata.gz: 8947ba953b888db638dff4ee913fa697eb80d0ac4e8796a705143278bae9aef67d7d966cdb6112309e2a51b5432fe340676036f2756bcbef885f801c929de13d
7
- data.tar.gz: c95c85e49f02acd99043120ccc6b7889b30553da9d968cdc08f7bfe13e130bd5f42380a68714c712f0444777a24bfcb29e252a28ce319f01242a362c9ca2e4e0
6
+ metadata.gz: a15603ba1c590a123d0cc086343c77e4b9239a2fea94ed7c99f37756f869c27e3b07705fd9e45c3cce75e4a6a64ae79fd1c26e9c1515ceeacb172ef214ee2800
7
+ data.tar.gz: be40d32a621aea85c184c39499dff819eeec6a51d953c5f5dba4446262811c97b3bd5e96b28c3767f01588da2ba207a9a82fe678df9fb915fe701d4671357620
@@ -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 = nil
41
+ @personal_api_key = opts[:personal_api_key]
38
42
 
39
43
  check_api_key!
40
44
 
41
- if opts[:personal_api_key]
42
- @personal_api_key = opts[:personal_api_key]
43
- @feature_flags_poller =
44
- FeatureFlagsPoller.new(
45
- opts[:feature_flags_polling_interval],
46
- opts[:personal_api_key],
47
- @api_key,
48
- opts[:host]
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, default_value = false)
124
- unless @personal_api_key
125
- logger.error(
126
- 'You need to specify a personal_api_key to use feature flags'
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
- is_enabled =
131
- @feature_flags_poller.is_feature_enabled(
132
- flag_key,
133
- distinct_id,
134
- default_value
135
- )
136
- capture(
137
- {
138
- 'distinct_id': distinct_id,
139
- 'event': '$feature_flag_called',
140
- 'properties': {
141
- '$feature_flag': flag_key,
142
- '$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,
143
189
  }
144
- }
145
- )
146
- 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)
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 use feature flags'
211
+ 'You need to specify a personal_api_key to locally evaluate feature flags'
153
212
  )
154
213
  return
155
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,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
 
@@ -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]')
@@ -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.3.0'
2
+ VERSION = '2.0.0'
3
3
  end
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.3.0
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-06-24 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
@@ -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.6
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: []