ablaevent-ruby 2.3.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 +7 -0
- data/bin/posthog +109 -0
- data/lib/posthog/backoff_policy.rb +44 -0
- data/lib/posthog/client.rb +291 -0
- data/lib/posthog/defaults.rb +39 -0
- data/lib/posthog/feature_flags.rb +466 -0
- data/lib/posthog/field_parser.rb +169 -0
- data/lib/posthog/logging.rb +68 -0
- data/lib/posthog/message_batch.rb +71 -0
- data/lib/posthog/noop_worker.rb +16 -0
- data/lib/posthog/response.rb +13 -0
- data/lib/posthog/send_worker.rb +67 -0
- data/lib/posthog/transport.rb +144 -0
- data/lib/posthog/utils.rb +132 -0
- data/lib/posthog/version.rb +3 -0
- data/lib/posthog-ruby.rb +1 -0
- data/lib/posthog.rb +9 -0
- metadata +185 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
require 'concurrent'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'posthog/version'
|
|
5
|
+
require 'posthog/logging'
|
|
6
|
+
require 'digest'
|
|
7
|
+
|
|
8
|
+
class PostHog
|
|
9
|
+
|
|
10
|
+
class InconclusiveMatchError < StandardError
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class FeatureFlagsPoller
|
|
14
|
+
include PostHog::Logging
|
|
15
|
+
include PostHog::Utils
|
|
16
|
+
|
|
17
|
+
def initialize(polling_interval, personal_api_key, project_api_key, host)
|
|
18
|
+
@polling_interval = polling_interval || 30
|
|
19
|
+
@personal_api_key = personal_api_key
|
|
20
|
+
@project_api_key = project_api_key
|
|
21
|
+
@host = host
|
|
22
|
+
@feature_flags = Concurrent::Array.new
|
|
23
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
24
|
+
@loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
|
|
25
|
+
@feature_flags_by_key = nil
|
|
26
|
+
|
|
27
|
+
@task =
|
|
28
|
+
Concurrent::TimerTask.new(
|
|
29
|
+
execution_interval: polling_interval,
|
|
30
|
+
) { _load_feature_flags }
|
|
31
|
+
|
|
32
|
+
# If no personal API key, disable local evaluation & thus polling for definitions
|
|
33
|
+
if @personal_api_key.nil?
|
|
34
|
+
logger.info "No personal API key provided, disabling local evaluation"
|
|
35
|
+
@loaded_flags_successfully_once.make_true
|
|
36
|
+
else
|
|
37
|
+
# load once before timer
|
|
38
|
+
load_feature_flags
|
|
39
|
+
@task.execute
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def load_feature_flags(force_reload = false)
|
|
44
|
+
if @loaded_flags_successfully_once.false? || force_reload
|
|
45
|
+
_load_feature_flags
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def get_feature_variants(distinct_id, groups={}, person_properties={}, group_properties={})
|
|
50
|
+
decide_data = get_decide(distinct_id, groups, person_properties, group_properties)
|
|
51
|
+
if !decide_data.key?(:featureFlags)
|
|
52
|
+
logger.error "Missing feature flags key: #{decide_data.to_json}"
|
|
53
|
+
else
|
|
54
|
+
stringify_keys(decide_data[:featureFlags] || {})
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def _get_active_feature_variants(distinct_id, groups={}, person_properties={}, group_properties={})
|
|
59
|
+
feature_variants = get_feature_variants(distinct_id, groups, person_properties, group_properties)
|
|
60
|
+
active_feature_variants = {}
|
|
61
|
+
feature_variants.each do |key, value|
|
|
62
|
+
if value != false
|
|
63
|
+
active_feature_variants[key] = value
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
active_feature_variants
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_feature_payloads(distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
|
70
|
+
decide_data = get_decide(distinct_id, groups, person_properties, group_properties)
|
|
71
|
+
if !decide_data.key?(:featureFlagPayloads)
|
|
72
|
+
logger.error "Missing feature flag payloads key: #{decide_data.to_json}"
|
|
73
|
+
else
|
|
74
|
+
stringify_keys(decide_data[:featureFlagPayloads] || {})
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_decide(distinct_id, groups={}, person_properties={}, group_properties={})
|
|
79
|
+
request_data = {
|
|
80
|
+
"distinct_id": distinct_id,
|
|
81
|
+
"groups": groups,
|
|
82
|
+
"person_properties": person_properties,
|
|
83
|
+
"group_properties": group_properties,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
decide_data = _request_feature_flag_evaluation(request_data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get_feature_flag(key, distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
|
90
|
+
# make sure they're loaded on first run
|
|
91
|
+
load_feature_flags
|
|
92
|
+
|
|
93
|
+
symbolize_keys! groups
|
|
94
|
+
symbolize_keys! person_properties
|
|
95
|
+
symbolize_keys! group_properties
|
|
96
|
+
|
|
97
|
+
group_properties.each do |key, value|
|
|
98
|
+
symbolize_keys! value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
response = nil
|
|
102
|
+
feature_flag = nil
|
|
103
|
+
|
|
104
|
+
@feature_flags.each do |flag|
|
|
105
|
+
if key == flag[:key]
|
|
106
|
+
feature_flag = flag
|
|
107
|
+
break
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if !feature_flag.nil?
|
|
112
|
+
begin
|
|
113
|
+
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
|
|
114
|
+
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
|
115
|
+
rescue InconclusiveMatchError => e
|
|
116
|
+
logger.debug "Failed to compute flag #{key} locally: #{e}"
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
logger.error "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
flag_was_locally_evaluated = !response.nil?
|
|
123
|
+
|
|
124
|
+
if !flag_was_locally_evaluated && !only_evaluate_locally
|
|
125
|
+
begin
|
|
126
|
+
flags = get_feature_variants(distinct_id, groups, person_properties, group_properties)
|
|
127
|
+
response = flags[key]
|
|
128
|
+
if response.nil?
|
|
129
|
+
response = false
|
|
130
|
+
end
|
|
131
|
+
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
logger.error "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
[response, flag_was_locally_evaluated]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def get_all_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
|
141
|
+
# returns a string hash of all flags
|
|
142
|
+
response = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties, only_evaluate_locally)
|
|
143
|
+
flags = response[:featureFlags]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get_all_flags_and_payloads(distinct_id, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
|
147
|
+
load_feature_flags
|
|
148
|
+
|
|
149
|
+
flags = {}
|
|
150
|
+
payloads = {}
|
|
151
|
+
fallback_to_decide = @feature_flags.empty?
|
|
152
|
+
|
|
153
|
+
@feature_flags.each do |flag|
|
|
154
|
+
begin
|
|
155
|
+
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
|
|
156
|
+
flags[flag[:key]] = match_value
|
|
157
|
+
match_payload = _compute_flag_payload_locally(flag[:key], match_value)
|
|
158
|
+
if match_payload
|
|
159
|
+
payloads[flag[:key]] = match_payload
|
|
160
|
+
end
|
|
161
|
+
rescue InconclusiveMatchError => e
|
|
162
|
+
fallback_to_decide = true
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
logger.error "Error computing flag locally: #{e}."
|
|
165
|
+
fallback_to_decide = true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
if fallback_to_decide && !only_evaluate_locally
|
|
169
|
+
begin
|
|
170
|
+
flags_and_payloads = get_decide(distinct_id, groups, person_properties, group_properties)
|
|
171
|
+
flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
|
|
172
|
+
payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
logger.error "Error computing flag remotely: #{e}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
{"featureFlags": flags, "featureFlagPayloads": payloads}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def get_feature_flag_payload(key, distinct_id, match_value = nil, groups = {}, person_properties = {}, group_properties = {}, only_evaluate_locally = false)
|
|
181
|
+
if match_value == nil
|
|
182
|
+
match_value = get_feature_flag(
|
|
183
|
+
key,
|
|
184
|
+
distinct_id,
|
|
185
|
+
groups,
|
|
186
|
+
person_properties,
|
|
187
|
+
group_properties,
|
|
188
|
+
true,
|
|
189
|
+
)[0]
|
|
190
|
+
end
|
|
191
|
+
response = nil
|
|
192
|
+
if match_value != nil
|
|
193
|
+
response = _compute_flag_payload_locally(key, match_value)
|
|
194
|
+
end
|
|
195
|
+
if response == nil and !only_evaluate_locally
|
|
196
|
+
decide_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
|
197
|
+
response = decide_payloads[key.downcase] || nil
|
|
198
|
+
end
|
|
199
|
+
response
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def shutdown_poller()
|
|
203
|
+
@task.shutdown
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Class methods
|
|
207
|
+
|
|
208
|
+
def self.match_property(property, property_values)
|
|
209
|
+
# only looks for matches where key exists in property_values
|
|
210
|
+
# doesn't support operator is_not_set
|
|
211
|
+
|
|
212
|
+
PostHog::Utils.symbolize_keys! property
|
|
213
|
+
PostHog::Utils.symbolize_keys! property_values
|
|
214
|
+
|
|
215
|
+
key = property[:key].to_sym
|
|
216
|
+
value = property[:value]
|
|
217
|
+
operator = property[:operator] || 'exact'
|
|
218
|
+
|
|
219
|
+
if !property_values.key?(key)
|
|
220
|
+
raise InconclusiveMatchError.new("Property #{key} not found in property_values")
|
|
221
|
+
elsif operator == 'is_not_set'
|
|
222
|
+
raise InconclusiveMatchError.new("Operator is_not_set not supported")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
override_value = property_values[key]
|
|
226
|
+
|
|
227
|
+
case operator
|
|
228
|
+
when 'exact'
|
|
229
|
+
value.is_a?(Array) ? value.include?(override_value) : value == override_value
|
|
230
|
+
when 'is_not'
|
|
231
|
+
value.is_a?(Array) ? !value.include?(override_value) : value != override_value
|
|
232
|
+
when'is_set'
|
|
233
|
+
property_values.key?(key)
|
|
234
|
+
when 'icontains'
|
|
235
|
+
override_value.to_s.downcase.include?(value.to_s.downcase)
|
|
236
|
+
when 'not_icontains'
|
|
237
|
+
!override_value.to_s.downcase.include?(value.to_s.downcase)
|
|
238
|
+
when 'regex'
|
|
239
|
+
PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
240
|
+
when 'not_regex'
|
|
241
|
+
PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
242
|
+
when 'gt'
|
|
243
|
+
override_value.class == value.class && override_value > value
|
|
244
|
+
when 'gte'
|
|
245
|
+
override_value.class == value.class && override_value >= value
|
|
246
|
+
when 'lt'
|
|
247
|
+
override_value.class == value.class && override_value < value
|
|
248
|
+
when 'lte'
|
|
249
|
+
override_value.class == value.class && override_value <= value
|
|
250
|
+
when 'is_date_before', 'is_date_after'
|
|
251
|
+
parsed_date = PostHog::Utils::convert_to_datetime(value)
|
|
252
|
+
override_date = PostHog::Utils::convert_to_datetime(override_value)
|
|
253
|
+
if operator == 'is_date_before'
|
|
254
|
+
return override_date < parsed_date
|
|
255
|
+
else
|
|
256
|
+
return override_date > parsed_date
|
|
257
|
+
end
|
|
258
|
+
else
|
|
259
|
+
logger.error "Unknown operator: #{operator}"
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
private
|
|
265
|
+
|
|
266
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
|
267
|
+
if flag[:ensure_experience_continuity]
|
|
268
|
+
raise InconclusiveMatchError.new("Flag has experience continuity enabled")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
return false if !flag[:active]
|
|
272
|
+
|
|
273
|
+
flag_filters = flag[:filters] || {}
|
|
274
|
+
|
|
275
|
+
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
|
276
|
+
if !aggregation_group_type_index.nil?
|
|
277
|
+
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
|
278
|
+
|
|
279
|
+
if group_name.nil?
|
|
280
|
+
logger.warn "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
|
281
|
+
# failover to `/decide/`
|
|
282
|
+
raise InconclusiveMatchError.new("Flag has unknown group type index")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
group_name_symbol = group_name.to_sym
|
|
286
|
+
|
|
287
|
+
if !groups.key?(group_name_symbol)
|
|
288
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
|
289
|
+
# don't failover to `/decide/`, since response will be the same
|
|
290
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
|
291
|
+
return false
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
focused_group_properties = group_properties[group_name_symbol]
|
|
295
|
+
return match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
|
|
296
|
+
else
|
|
297
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def _compute_flag_payload_locally(key, match_value)
|
|
303
|
+
response = nil
|
|
304
|
+
|
|
305
|
+
if [true, false].include? match_value
|
|
306
|
+
response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_s.to_sym)
|
|
307
|
+
elsif match_value.is_a? String
|
|
308
|
+
response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_sym)
|
|
309
|
+
end
|
|
310
|
+
response
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def match_feature_flag_properties(flag, distinct_id, properties)
|
|
314
|
+
flag_filters = flag[:filters] || {}
|
|
315
|
+
|
|
316
|
+
flag_conditions = flag_filters[:groups] || []
|
|
317
|
+
is_inconclusive = false
|
|
318
|
+
result = nil
|
|
319
|
+
|
|
320
|
+
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
|
321
|
+
# evaluated first, and the variant override is applied to the first matching condition.
|
|
322
|
+
sorted_flag_conditions = flag_conditions.each_with_index.sort_by { |condition, idx| [condition[:variant].nil? ? 1 : -1, idx] }
|
|
323
|
+
|
|
324
|
+
sorted_flag_conditions.each do |condition, idx|
|
|
325
|
+
begin
|
|
326
|
+
if is_condition_match(flag, distinct_id, condition, properties)
|
|
327
|
+
variant_override = condition[:variant]
|
|
328
|
+
flag_multivariate = flag_filters[:multivariate] || {}
|
|
329
|
+
flag_variants = flag_multivariate[:variants] || []
|
|
330
|
+
if flag_variants.map{|variant| variant[:key]}.include?(condition[:variant])
|
|
331
|
+
variant = variant_override
|
|
332
|
+
else
|
|
333
|
+
variant = get_matching_variant(flag, distinct_id)
|
|
334
|
+
end
|
|
335
|
+
result = variant || true
|
|
336
|
+
break
|
|
337
|
+
end
|
|
338
|
+
rescue InconclusiveMatchError => e
|
|
339
|
+
is_inconclusive = true
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if !result.nil?
|
|
344
|
+
return result
|
|
345
|
+
elsif is_inconclusive
|
|
346
|
+
raise InconclusiveMatchError.new("Can't determine if feature flag is enabled or not with given properties")
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# We can only return False when all conditions are False
|
|
350
|
+
return false
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def is_condition_match(flag, distinct_id, condition, properties)
|
|
354
|
+
rollout_percentage = condition[:rollout_percentage]
|
|
355
|
+
|
|
356
|
+
if !(condition[:properties] || []).empty?
|
|
357
|
+
if !condition[:properties].all? { |prop|
|
|
358
|
+
FeatureFlagsPoller.match_property(prop, properties)
|
|
359
|
+
}
|
|
360
|
+
return false
|
|
361
|
+
elsif rollout_percentage.nil?
|
|
362
|
+
return true
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
if !rollout_percentage.nil? and _hash(flag[:key], distinct_id) > (rollout_percentage.to_f/100)
|
|
367
|
+
return false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
return true
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
374
|
+
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
375
|
+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
376
|
+
# we can do _hash(key, distinct_id) < 0.2
|
|
377
|
+
def _hash(key, distinct_id, salt="")
|
|
378
|
+
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
|
379
|
+
return (Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def get_matching_variant(flag, distinct_id)
|
|
383
|
+
hash_value = _hash(flag[:key], distinct_id, salt="variant")
|
|
384
|
+
matching_variant = variant_lookup_table(flag).find { |variant|
|
|
385
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
|
386
|
+
}
|
|
387
|
+
matching_variant.nil? ? nil : matching_variant[:key]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def variant_lookup_table(flag)
|
|
391
|
+
lookup_table = []
|
|
392
|
+
value_min = 0
|
|
393
|
+
flag_filters = flag[:filters] || {}
|
|
394
|
+
variants = flag_filters[:multivariate] || {}
|
|
395
|
+
multivariates = variants[:variants] || []
|
|
396
|
+
multivariates.each do |variant|
|
|
397
|
+
value_max = value_min + variant[:rollout_percentage].to_f / 100
|
|
398
|
+
lookup_table << {'value_min': value_min, 'value_max': value_max, 'key': variant[:key]}
|
|
399
|
+
value_min = value_max
|
|
400
|
+
end
|
|
401
|
+
return lookup_table
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def _load_feature_flags()
|
|
405
|
+
res = _request_feature_flag_definitions
|
|
406
|
+
|
|
407
|
+
if !res.key?(:flags)
|
|
408
|
+
logger.error "Failed to load feature flags: #{res}"
|
|
409
|
+
else
|
|
410
|
+
@feature_flags = res[:flags] || []
|
|
411
|
+
@feature_flags_by_key = {}
|
|
412
|
+
@feature_flags.each do |flag|
|
|
413
|
+
if flag[:key] != nil
|
|
414
|
+
@feature_flags_by_key[flag[:key]] = flag
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
@group_type_mapping = res[:group_type_mapping] || {}
|
|
418
|
+
|
|
419
|
+
logger.debug "Loaded #{@feature_flags.length} feature flags"
|
|
420
|
+
if @loaded_flags_successfully_once.false?
|
|
421
|
+
@loaded_flags_successfully_once.make_true
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def _request_feature_flag_definitions
|
|
427
|
+
uri = URI("#{@host}/api/feature_flag/local_evaluation?token=#{@project_api_key}")
|
|
428
|
+
req = Net::HTTP::Get.new(uri)
|
|
429
|
+
req['Authorization'] = "Bearer #{@personal_api_key}"
|
|
430
|
+
|
|
431
|
+
_request(uri, req)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def _request_feature_flag_evaluation(data={})
|
|
435
|
+
uri = URI("#{@host}/decide/?v=3")
|
|
436
|
+
req = Net::HTTP::Post.new(uri)
|
|
437
|
+
req['Content-Type'] = 'application/json'
|
|
438
|
+
data['token'] = @project_api_key
|
|
439
|
+
req.body = data.to_json
|
|
440
|
+
|
|
441
|
+
_request(uri, req)
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def _request(uri, request_object)
|
|
445
|
+
|
|
446
|
+
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
|
|
447
|
+
|
|
448
|
+
begin
|
|
449
|
+
res_body = nil
|
|
450
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
451
|
+
res = http.request(request_object)
|
|
452
|
+
JSON.parse(res.body, {symbolize_names: true})
|
|
453
|
+
end
|
|
454
|
+
rescue Timeout::Error,
|
|
455
|
+
Errno::EINVAL,
|
|
456
|
+
Errno::ECONNRESET,
|
|
457
|
+
EOFError,
|
|
458
|
+
Net::HTTPBadResponse,
|
|
459
|
+
Net::HTTPHeaderSyntaxError,
|
|
460
|
+
Net::ProtocolError => e
|
|
461
|
+
logger.debug("Unable to complete request to #{uri}")
|
|
462
|
+
throw e
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
class PostHog
|
|
2
|
+
class FieldParser
|
|
3
|
+
class << self
|
|
4
|
+
include PostHog::Utils
|
|
5
|
+
|
|
6
|
+
# In addition to the common fields, capture accepts:
|
|
7
|
+
#
|
|
8
|
+
# - "event"
|
|
9
|
+
# - "properties"
|
|
10
|
+
# - "groups"
|
|
11
|
+
def parse_for_capture(fields)
|
|
12
|
+
common = parse_common_fields(fields)
|
|
13
|
+
|
|
14
|
+
event = fields[:event]
|
|
15
|
+
properties = fields[:properties] || {}
|
|
16
|
+
groups = fields[:groups]
|
|
17
|
+
|
|
18
|
+
check_presence!(event, 'event')
|
|
19
|
+
check_is_hash!(properties, 'properties')
|
|
20
|
+
|
|
21
|
+
if groups
|
|
22
|
+
check_is_hash!(groups, 'groups')
|
|
23
|
+
properties["$groups"] = groups
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
isoify_dates! properties
|
|
27
|
+
|
|
28
|
+
common.merge(
|
|
29
|
+
{
|
|
30
|
+
type: 'capture',
|
|
31
|
+
event: event.to_s,
|
|
32
|
+
properties: properties.merge(common[:properties] || {})
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# In addition to the common fields, identify accepts:
|
|
38
|
+
#
|
|
39
|
+
# - "properties"
|
|
40
|
+
def parse_for_identify(fields)
|
|
41
|
+
common = parse_common_fields(fields)
|
|
42
|
+
|
|
43
|
+
properties = fields[:properties] || {}
|
|
44
|
+
check_is_hash!(properties, 'properties')
|
|
45
|
+
|
|
46
|
+
isoify_dates! properties
|
|
47
|
+
|
|
48
|
+
common.merge(
|
|
49
|
+
{
|
|
50
|
+
type: 'identify',
|
|
51
|
+
event: '$identify',
|
|
52
|
+
'$set': properties,
|
|
53
|
+
properties: properties.merge(common[:properties] || {})
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
end
|
|
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
|
+
|
|
85
|
+
# In addition to the common fields, alias accepts:
|
|
86
|
+
#
|
|
87
|
+
# - "alias"
|
|
88
|
+
def parse_for_alias(fields)
|
|
89
|
+
common = parse_common_fields(fields)
|
|
90
|
+
|
|
91
|
+
distinct_id = common[:distinct_id] # must both be set and move to properties
|
|
92
|
+
|
|
93
|
+
alias_field = fields[:alias]
|
|
94
|
+
check_presence! alias_field, 'alias'
|
|
95
|
+
|
|
96
|
+
common.merge(
|
|
97
|
+
{
|
|
98
|
+
type: 'alias',
|
|
99
|
+
event: '$create_alias',
|
|
100
|
+
distinct_id: distinct_id,
|
|
101
|
+
properties:
|
|
102
|
+
{ distinct_id: distinct_id, alias: alias_field }.merge(
|
|
103
|
+
common[:properties] || {}
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Common fields are:
|
|
112
|
+
#
|
|
113
|
+
# - "timestamp"
|
|
114
|
+
# - "distinct_id"
|
|
115
|
+
# - "message_id"
|
|
116
|
+
# - "send_feature_flags"
|
|
117
|
+
def parse_common_fields(fields)
|
|
118
|
+
timestamp = fields[:timestamp] || Time.new
|
|
119
|
+
distinct_id = fields[:distinct_id]
|
|
120
|
+
message_id = fields[:message_id].to_s if fields[:message_id]
|
|
121
|
+
send_feature_flags = fields[:send_feature_flags]
|
|
122
|
+
|
|
123
|
+
check_timestamp! timestamp
|
|
124
|
+
check_presence! distinct_id, 'distinct_id'
|
|
125
|
+
|
|
126
|
+
parsed = {
|
|
127
|
+
timestamp: datetime_in_iso8601(timestamp),
|
|
128
|
+
library: 'posthog-ruby',
|
|
129
|
+
library_version: PostHog::VERSION.to_s,
|
|
130
|
+
messageId: message_id,
|
|
131
|
+
distinct_id: distinct_id,
|
|
132
|
+
properties: {
|
|
133
|
+
'$lib' => 'posthog-ruby',
|
|
134
|
+
'$lib_version' => PostHog::VERSION.to_s
|
|
135
|
+
}
|
|
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
|
|
145
|
+
parsed
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def check_timestamp!(timestamp)
|
|
149
|
+
unless timestamp.is_a? Time
|
|
150
|
+
raise ArgumentError, 'Timestamp must be a Time'
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# private: Ensures that a string is non-empty
|
|
155
|
+
#
|
|
156
|
+
# obj - String|Number that must be non-blank
|
|
157
|
+
# name - Name of the validated value
|
|
158
|
+
def check_presence!(obj, name)
|
|
159
|
+
if obj.nil? || (obj.is_a?(String) && obj.empty?)
|
|
160
|
+
raise ArgumentError, "#{name} must be given"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def check_is_hash!(obj, name)
|
|
165
|
+
raise ArgumentError, "#{name} must be a Hash" unless obj.is_a? Hash
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
|
|
3
|
+
class PostHog
|
|
4
|
+
# Wraps an existing logger and adds a prefix to all messages
|
|
5
|
+
class PrefixedLogger
|
|
6
|
+
def initialize(logger, prefix)
|
|
7
|
+
@logger = logger
|
|
8
|
+
@prefix = prefix
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def debug(msg)
|
|
12
|
+
@logger.debug("#{@prefix} #{msg}")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def info(msg)
|
|
16
|
+
@logger.info("#{@prefix} #{msg}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def warn(msg)
|
|
20
|
+
@logger.warn("#{@prefix} #{msg}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def error(msg)
|
|
24
|
+
@logger.error("#{@prefix} #{msg}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def level=(severity)
|
|
28
|
+
@logger.level = severity
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def level
|
|
32
|
+
@logger.level
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module Logging
|
|
37
|
+
class << self
|
|
38
|
+
def logger
|
|
39
|
+
return @logger if @logger
|
|
40
|
+
|
|
41
|
+
base_logger =
|
|
42
|
+
if defined?(Rails)
|
|
43
|
+
Rails.logger
|
|
44
|
+
else
|
|
45
|
+
logger = Logger.new STDOUT
|
|
46
|
+
logger.progname = 'PostHog'
|
|
47
|
+
logger.level = Logger::WARN
|
|
48
|
+
logger
|
|
49
|
+
end
|
|
50
|
+
@logger = PrefixedLogger.new(base_logger, '[posthog-ruby]')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_writer :logger
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.included(base)
|
|
57
|
+
class << base
|
|
58
|
+
def logger
|
|
59
|
+
Logging.logger
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def logger
|
|
65
|
+
Logging.logger
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|