posthog-ruby 1.3.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cc2c1e12c53fc80013ff8d49d082d1d35ad625fa0db972e53afbc205f595664
4
- data.tar.gz: f95c1cb5502e0ae2d58e10b0575065fb797bbccd73d96098249a50ab6f60ee0a
3
+ metadata.gz: ef38f429ce00bcdcfc5c861fd163c420b9b3823b7cbcef4019a8bad580a6ad6f
4
+ data.tar.gz: '08effac4bc47b38a6e67fe2f578cd67a974a7462dcaa6e73e92ac11b5bb34333'
5
5
  SHA512:
6
- metadata.gz: 8947ba953b888db638dff4ee913fa697eb80d0ac4e8796a705143278bae9aef67d7d966cdb6112309e2a51b5432fe340676036f2756bcbef885f801c929de13d
7
- data.tar.gz: c95c85e49f02acd99043120ccc6b7889b30553da9d968cdc08f7bfe13e130bd5f42380a68714c712f0444777a24bfcb29e252a28ce319f01242a362c9ca2e4e0
6
+ metadata.gz: 86892110210900d5a3faa13fe2c335ce7d1ce0390390ff25c3d8a29510187d8829bda4795ead866471aaab7fa333f50a9b8251ff8e4ae47411d7a601d9f15eb2
7
+ data.tar.gz: 80374572728723780c1930555491445a4761685df6574305c0de73f9d0fbd1630924c5fd4412b469f735bd44d7657b7160d73ccb4094c978b58bb7494950bb32
@@ -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,386 @@ require 'json'
4
4
  require 'posthog/version'
5
5
  require 'posthog/logging'
6
6
  require 'digest'
7
+
7
8
  class PostHog
9
+
10
+ class InconclusiveMatchError < StandardError
11
+ end
12
+
13
+ class DecideAPIError < StandardError
14
+ end
15
+
8
16
  class FeatureFlagsPoller
9
17
  include PostHog::Logging
18
+ include PostHog::Utils
10
19
 
11
20
  def initialize(polling_interval, personal_api_key, project_api_key, host)
12
- @polling_interval = polling_interval || 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
28
43
  end
29
44
 
30
- def is_feature_enabled(key, distinct_id, default_result = false)
45
+ def load_feature_flags(force_reload = false)
46
+ if @loaded_flags_successfully_once.false? || force_reload
47
+ _load_feature_flags
48
+ end
49
+ end
50
+
51
+ def get_feature_variants(distinct_id, groups={}, person_properties={}, group_properties={})
52
+
53
+ request_data = {
54
+ "distinct_id": distinct_id,
55
+ "groups": groups,
56
+ "person_properties": person_properties,
57
+ "group_properties": group_properties,
58
+ }
59
+
60
+ decide_data = _request_feature_flag_evaluation(request_data)
61
+
62
+ if !decide_data.key?(:featureFlags)
63
+ raise DecideAPIError.new(decide_data.to_json)
64
+ else
65
+ stringify_keys(decide_data[:featureFlags] || {})
66
+ end
67
+ end
68
+
69
+ def get_feature_flag(key, distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
31
70
  # make sure they're loaded on first run
32
71
  load_feature_flags
33
72
 
34
- 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
47
101
 
48
- flag_rollout_pctg =
49
- if feature_flag['rollout_percentage']
50
- feature_flag['rollout_percentage']
51
- else
52
- 100
102
+ flag_was_locally_evaluated = !response.nil?
103
+
104
+ if !flag_was_locally_evaluated && !only_evaluate_locally
105
+ begin
106
+ flags = get_feature_variants(distinct_id, groups, person_properties, group_properties)
107
+ response = flags[key]
108
+ if response.nil?
109
+ response = false
110
+ end
111
+ logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
112
+ rescue StandardError => e
113
+ logger.error "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}"
53
114
  end
54
- if feature_flag['is_simple_flag']
55
- return is_simple_flag_enabled(key, distinct_id, flag_rollout_pctg)
56
- else
57
- data = { 'distinct_id' => distinct_id }
58
- res = _request('POST', 'decide', false, data)
59
- return res['featureFlags'].include? key
60
115
  end
61
116
 
62
- 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
+ when 'is_date_before', 'is_date_after'
200
+ parsed_date = PostHog::Utils::convert_to_datetime(value)
201
+ override_date = PostHog::Utils::convert_to_datetime(override_value)
202
+ if operator == 'is_date_before'
203
+ return override_date < parsed_date
204
+ else
205
+ return override_date > parsed_date
206
+ end
207
+ else
208
+ logger.error "Unknown operator: #{operator}"
209
+ false
210
+ end
211
+ end
212
+
83
213
  private
84
214
 
85
- def _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
215
+ def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
216
+ if flag[:ensure_experience_continuity]
217
+ raise InconclusiveMatchError.new("Flag has experience continuity enabled")
91
218
  end
219
+
220
+ return false if !flag[:active]
221
+
222
+ flag_filters = flag[:filters] || {}
223
+
224
+ aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
225
+ if !aggregation_group_type_index.nil?
226
+ group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
227
+
228
+ if group_name.nil?
229
+ logger.warn "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
230
+ # failover to `/decide/`
231
+ raise InconclusiveMatchError.new("Flag has unknown group type index")
232
+ end
233
+
234
+ group_name_symbol = group_name.to_sym
235
+
236
+ if !groups.key?(group_name_symbol)
237
+ # Group flags are never enabled if appropriate `groups` aren't passed in
238
+ # don't failover to `/decide/`, since response will be the same
239
+ logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
240
+ return false
241
+ end
242
+
243
+ focused_group_properties = group_properties[group_name_symbol]
244
+ return match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
245
+ else
246
+ return match_feature_flag_properties(flag, distinct_id, person_properties)
247
+ end
248
+
92
249
  end
93
250
 
94
- def _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}"
251
+ def match_feature_flag_properties(flag, distinct_id, properties)
252
+ flag_filters = flag[:filters] || {}
253
+
254
+ flag_conditions = flag_filters[:groups] || []
255
+ is_inconclusive = false
256
+ result = nil
257
+
258
+ # Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
259
+ # evaluated first, and the variant override is applied to the first matching condition.
260
+ sorted_flag_conditions = flag_conditions.each_with_index.sort_by { |condition, idx| [condition[:variant].nil? ? 1 : -1, idx] }
261
+
262
+ sorted_flag_conditions.each do |condition, idx|
263
+ begin
264
+ if is_condition_match(flag, distinct_id, condition, properties)
265
+ variant_override = condition[:variant]
266
+ flag_multivariate = flag_filters[:multivariate] || {}
267
+ flag_variants = flag_multivariate[:variants] || []
268
+ if flag_variants.map{|variant| variant[:key]}.include?(condition[:variant])
269
+ variant = variant_override
270
+ else
271
+ variant = get_matching_variant(flag, distinct_id)
272
+ end
273
+ result = variant || true
274
+ break
275
+ end
276
+ rescue InconclusiveMatchError => e
277
+ is_inconclusive = true
278
+ end
279
+ end
280
+
281
+ if !result.nil?
282
+ return result
283
+ elsif is_inconclusive
284
+ raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties")
285
+ end
286
+
287
+ # We can only return False when all conditions are False
288
+ return false
289
+ end
290
+
291
+ def is_condition_match(flag, distinct_id, condition, properties)
292
+ rollout_percentage = condition[:rollout_percentage]
293
+
294
+ if !(condition[:properties] || []).empty?
295
+ if !condition[:properties].all? { |prop|
296
+ FeatureFlagsPoller.match_property(prop, properties)
297
+ }
298
+ return false
299
+ elsif rollout_percentage.nil?
300
+ return true
301
+ end
302
+ end
303
+
304
+ if !rollout_percentage.nil? and _hash(flag[:key], distinct_id) > (rollout_percentage.to_f/100)
305
+ return false
306
+ end
307
+
308
+ return true
309
+ end
310
+
311
+ # This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
312
+ # Given the same distinct_id and key, it'll always return the same float. These floats are
313
+ # uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
314
+ # we can do _hash(key, distinct_id) < 0.2
315
+ def _hash(key, distinct_id, salt="")
316
+ hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
317
+ return (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
318
+ end
319
+
320
+ def get_matching_variant(flag, distinct_id)
321
+ hash_value = _hash(flag[:key], distinct_id, salt="variant")
322
+ matching_variant = variant_lookup_table(flag).find { |variant|
323
+ hash_value >= variant[:value_min] and hash_value < variant[:value_max]
324
+ }
325
+ matching_variant.nil? ? nil : matching_variant[:key]
326
+ end
327
+
328
+ def variant_lookup_table(flag)
329
+ lookup_table = []
330
+ value_min = 0
331
+ flag_filters = flag[:filters] || {}
332
+ variants = flag_filters[:multivariate] || {}
333
+ multivariates = variants[:variants] || []
334
+ multivariates.each do |variant|
335
+ value_max = value_min + variant[:rollout_percentage].to_f / 100
336
+ lookup_table << {'value_min': value_min, 'value_max': value_max, 'key': variant[:key]}
337
+ value_min = value_max
338
+ end
339
+ return lookup_table
340
+ end
341
+
342
+ def _load_feature_flags()
343
+ res = _request_feature_flag_definitions
344
+ @feature_flags.clear
345
+
346
+ if !res.key?(:flags)
347
+ logger.error "Failed to load feature flags: #{res}"
100
348
  else
101
- req = Net::HTTP::Post.new(uri)
102
- req['Content-Type'] = 'application/json'
103
- data['token'] = @project_api_key
104
- req.body = data.to_json
349
+ @feature_flags = res[:flags] || []
350
+ @group_type_mapping = res[:group_type_mapping] || {}
351
+
352
+ logger.debug "Loaded #{@feature_flags.length} feature flags"
353
+ if @loaded_flags_successfully_once.false?
354
+ @loaded_flags_successfully_once.make_true
355
+ end
105
356
  end
357
+ end
358
+
359
+ def _request_feature_flag_definitions
360
+ uri = URI("#{@host}/api/feature_flag/local_evaluation?token=#{@project_api_key}")
361
+ req = Net::HTTP::Get.new(uri)
362
+ req['Authorization'] = "Bearer #{@personal_api_key}"
363
+
364
+ _request(uri, req)
365
+ end
106
366
 
107
- req['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
367
+ def _request_feature_flag_evaluation(data={})
368
+ uri = URI("#{@host}/decide/?v=2")
369
+ req = Net::HTTP::Post.new(uri)
370
+ req['Content-Type'] = 'application/json'
371
+ data['token'] = @project_api_key
372
+ req.body = data.to_json
373
+
374
+ _request(uri, req)
375
+ end
376
+
377
+ def _request(uri, request_object)
378
+
379
+ request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
108
380
 
109
381
  begin
110
382
  res_body = nil
111
- 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
383
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
384
+ res = http.request(request_object)
385
+ JSON.parse(res.body, {symbolize_names: true})
386
+ end
117
387
  rescue Timeout::Error,
118
388
  Errno::EINVAL,
119
389
  Errno::ECONNRESET,
@@ -124,6 +394,6 @@ class PostHog
124
394
  logger.debug("Unable to complete request to #{uri}")
125
395
  throw e
126
396
  end
127
- end
397
+ end
128
398
  end
129
399
  end
@@ -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
@@ -1,6 +1,10 @@
1
1
  require 'securerandom'
2
2
 
3
3
  class PostHog
4
+
5
+ class InconclusiveMatchError < StandardError
6
+ end
7
+
4
8
  module Utils
5
9
  extend self
6
10
 
@@ -83,7 +87,46 @@ class PostHog
83
87
  ]
84
88
  end
85
89
 
90
+ def convert_to_datetime(value)
91
+ if value.respond_to?(:strftime)
92
+ parsed_date = value
93
+ return parsed_date
94
+ elsif value.respond_to?(:to_str)
95
+ begin
96
+ parsed_date = DateTime.parse(value)
97
+ return parsed_date
98
+ rescue ArgumentError => e
99
+ raise InconclusiveMatchError.new("#{value} is not in a valid date format")
100
+ end
101
+ else
102
+ raise InconclusiveMatchError.new("The date provided must be a string or date object")
103
+ end
104
+ end
105
+
86
106
  UTC_OFFSET_WITH_COLON = '%s%02d:%02d'
87
107
  UTC_OFFSET_WITHOUT_COLON = UTC_OFFSET_WITH_COLON.sub(':', '')
108
+
109
+ def is_valid_regex(regex)
110
+ begin
111
+ Regexp.new(regex)
112
+ return true
113
+ rescue RegexpError
114
+ return false
115
+ end
116
+ end
117
+
118
+ class SizeLimitedHash < Hash
119
+ def initialize(max_length, *args, &block)
120
+ super(*args, &block)
121
+ @max_length = max_length
122
+ end
123
+
124
+ def []=(key, value)
125
+ if length >= @max_length
126
+ clear
127
+ end
128
+ super
129
+ end
130
+ end
88
131
  end
89
132
  end
@@ -1,3 +1,3 @@
1
1
  class PostHog
2
- VERSION = '1.3.0'
2
+ VERSION = '2.1.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.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ''
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-06-24 00:00:00.000000000 Z
11
+ date: 2022-11-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - ">="
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1'
20
+ - - "<"
18
21
  - !ruby/object:Gem::Version
19
- version: '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: []