posthog-ruby 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/bin/posthog +7 -7
- data/lib/posthog/client.rb +120 -44
- data/lib/posthog/defaults.rb +4 -5
- data/lib/posthog/feature_flag.rb +27 -26
- data/lib/posthog/feature_flags.rb +247 -177
- data/lib/posthog/field_parser.rb +30 -19
- data/lib/posthog/logging.rb +1 -1
- data/lib/posthog/noop_worker.rb +2 -1
- data/lib/posthog/send_worker.rb +2 -1
- data/lib/posthog/transport.rb +8 -10
- data/lib/posthog/utils.rb +21 -28
- data/lib/posthog/version.rb +1 -1
- data/lib/posthog-ruby.rb +2 -0
- metadata +5 -105
@@ -7,7 +7,6 @@ require 'posthog/feature_flag'
|
|
7
7
|
require 'digest'
|
8
8
|
|
9
9
|
class PostHog
|
10
|
-
|
11
10
|
class InconclusiveMatchError < StandardError
|
12
11
|
end
|
13
12
|
|
@@ -15,7 +14,14 @@ class PostHog
|
|
15
14
|
include PostHog::Logging
|
16
15
|
include PostHog::Utils
|
17
16
|
|
18
|
-
def initialize(
|
17
|
+
def initialize(
|
18
|
+
polling_interval,
|
19
|
+
personal_api_key,
|
20
|
+
project_api_key,
|
21
|
+
host,
|
22
|
+
feature_flag_request_timeout_seconds,
|
23
|
+
on_error = nil
|
24
|
+
)
|
19
25
|
@polling_interval = polling_interval || 30
|
20
26
|
@personal_api_key = personal_api_key
|
21
27
|
@project_api_key = project_api_key
|
@@ -29,12 +35,12 @@ class PostHog
|
|
29
35
|
@quota_limited = Concurrent::AtomicBoolean.new(false)
|
30
36
|
@task =
|
31
37
|
Concurrent::TimerTask.new(
|
32
|
-
execution_interval: polling_interval
|
38
|
+
execution_interval: polling_interval
|
33
39
|
) { _load_feature_flags }
|
34
40
|
|
35
41
|
# If no personal API key, disable local evaluation & thus polling for definitions
|
36
42
|
if @personal_api_key.nil?
|
37
|
-
logger.info
|
43
|
+
logger.info 'No personal API key provided, disabling local evaluation'
|
38
44
|
@loaded_flags_successfully_once.make_true
|
39
45
|
else
|
40
46
|
# load once before timer
|
@@ -44,45 +50,71 @@ class PostHog
|
|
44
50
|
end
|
45
51
|
|
46
52
|
def load_feature_flags(force_reload = false)
|
47
|
-
|
48
|
-
|
49
|
-
|
53
|
+
return unless @loaded_flags_successfully_once.false? || force_reload
|
54
|
+
|
55
|
+
_load_feature_flags
|
50
56
|
end
|
51
57
|
|
52
|
-
def get_feature_variants(
|
58
|
+
def get_feature_variants(
|
59
|
+
distinct_id,
|
60
|
+
groups = {},
|
61
|
+
person_properties = {},
|
62
|
+
group_properties = {},
|
63
|
+
raise_on_error = false
|
64
|
+
)
|
53
65
|
# TODO: Convert to options hash for easier argument passing
|
54
|
-
flags_data = get_all_flags_and_payloads(
|
55
|
-
|
66
|
+
flags_data = get_all_flags_and_payloads(
|
67
|
+
distinct_id,
|
68
|
+
groups,
|
69
|
+
person_properties,
|
70
|
+
group_properties,
|
71
|
+
false,
|
72
|
+
raise_on_error
|
73
|
+
)
|
74
|
+
|
75
|
+
if flags_data.key?(:featureFlags)
|
76
|
+
stringify_keys(flags_data[:featureFlags] || {})
|
77
|
+
else
|
56
78
|
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
57
79
|
{}
|
58
|
-
else
|
59
|
-
stringify_keys(flags_data[:featureFlags] || {})
|
60
80
|
end
|
61
81
|
end
|
62
82
|
|
63
|
-
def get_feature_payloads(
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
83
|
+
def get_feature_payloads(
|
84
|
+
distinct_id,
|
85
|
+
groups = {},
|
86
|
+
person_properties = {},
|
87
|
+
group_properties = {},
|
88
|
+
_only_evaluate_locally = false
|
89
|
+
)
|
90
|
+
flags_data = get_all_flags_and_payloads(
|
91
|
+
distinct_id,
|
92
|
+
groups,
|
93
|
+
person_properties,
|
94
|
+
group_properties
|
95
|
+
)
|
96
|
+
|
97
|
+
if flags_data.key?(:featureFlagPayloads)
|
69
98
|
stringify_keys(flags_data[:featureFlagPayloads] || {})
|
99
|
+
else
|
100
|
+
logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
|
101
|
+
{}
|
70
102
|
end
|
71
103
|
end
|
72
104
|
|
73
|
-
def get_flags(distinct_id, groups={}, person_properties={}, group_properties={})
|
105
|
+
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
74
106
|
request_data = {
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
107
|
+
distinct_id: distinct_id,
|
108
|
+
groups: groups,
|
109
|
+
person_properties: person_properties,
|
110
|
+
group_properties: group_properties
|
79
111
|
}
|
80
112
|
|
81
113
|
flags_response = _request_feature_flag_evaluation(request_data)
|
82
114
|
|
83
115
|
# Only normalize if we have flags in the response
|
84
116
|
if flags_response[:flags]
|
85
|
-
#v4 format
|
117
|
+
# v4 format
|
86
118
|
flags_hash = flags_response[:flags].transform_values do |flag|
|
87
119
|
FeatureFlag.new(flag)
|
88
120
|
end
|
@@ -90,7 +122,7 @@ class PostHog
|
|
90
122
|
flags_response[:featureFlags] = flags_hash.transform_values(&:get_value).transform_keys(&:to_sym)
|
91
123
|
flags_response[:featureFlagPayloads] = flags_hash.transform_values(&:payload).transform_keys(&:to_sym)
|
92
124
|
elsif flags_response[:featureFlags]
|
93
|
-
#v3 format
|
125
|
+
# v3 format
|
94
126
|
flags_response[:featureFlags] = flags_response[:featureFlags] || {}
|
95
127
|
flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
|
96
128
|
flags_response[:flags] = flags_response[:featureFlags].map do |key, value|
|
@@ -101,10 +133,17 @@ class PostHog
|
|
101
133
|
end
|
102
134
|
|
103
135
|
def get_remote_config_payload(flag_key)
|
104
|
-
|
136
|
+
_request_remote_config_payload(flag_key)
|
105
137
|
end
|
106
138
|
|
107
|
-
def get_feature_flag(
|
139
|
+
def get_feature_flag(
|
140
|
+
key,
|
141
|
+
distinct_id,
|
142
|
+
groups = {},
|
143
|
+
person_properties = {},
|
144
|
+
group_properties = {},
|
145
|
+
only_evaluate_locally = false
|
146
|
+
)
|
108
147
|
# make sure they're loaded on first run
|
109
148
|
load_feature_flags
|
110
149
|
|
@@ -112,8 +151,8 @@ class PostHog
|
|
112
151
|
symbolize_keys! person_properties
|
113
152
|
symbolize_keys! group_properties
|
114
153
|
|
115
|
-
group_properties.
|
116
|
-
symbolize_keys!
|
154
|
+
group_properties.each_value do |value|
|
155
|
+
symbolize_keys!(value)
|
117
156
|
end
|
118
157
|
|
119
158
|
response = nil
|
@@ -126,7 +165,7 @@ class PostHog
|
|
126
165
|
end
|
127
166
|
end
|
128
167
|
|
129
|
-
|
168
|
+
unless feature_flag.nil?
|
130
169
|
begin
|
131
170
|
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
|
132
171
|
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
@@ -144,18 +183,16 @@ class PostHog
|
|
144
183
|
if !flag_was_locally_evaluated && !only_evaluate_locally
|
145
184
|
begin
|
146
185
|
flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties, false, true)
|
147
|
-
if
|
148
|
-
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
149
|
-
flags = {}
|
150
|
-
else
|
186
|
+
if flags_data.key?(:featureFlags)
|
151
187
|
flags = stringify_keys(flags_data[:featureFlags] || {})
|
152
188
|
request_id = flags_data[:requestId]
|
189
|
+
else
|
190
|
+
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
191
|
+
flags = {}
|
153
192
|
end
|
154
193
|
|
155
194
|
response = flags[key]
|
156
|
-
if response.nil?
|
157
|
-
response = false
|
158
|
-
end
|
195
|
+
response = false if response.nil?
|
159
196
|
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
160
197
|
rescue StandardError => e
|
161
198
|
@on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
|
@@ -165,17 +202,38 @@ class PostHog
|
|
165
202
|
[response, flag_was_locally_evaluated, request_id]
|
166
203
|
end
|
167
204
|
|
168
|
-
def get_all_flags(
|
205
|
+
def get_all_flags(
|
206
|
+
distinct_id,
|
207
|
+
groups = {},
|
208
|
+
person_properties = {},
|
209
|
+
group_properties = {},
|
210
|
+
only_evaluate_locally = false
|
211
|
+
)
|
169
212
|
if @quota_limited.true?
|
170
|
-
logger.debug
|
213
|
+
logger.debug 'Not fetching flags from server - quota limited'
|
171
214
|
return {}
|
172
215
|
end
|
216
|
+
|
173
217
|
# returns a string hash of all flags
|
174
|
-
response = get_all_flags_and_payloads(
|
218
|
+
response = get_all_flags_and_payloads(
|
219
|
+
distinct_id,
|
220
|
+
groups,
|
221
|
+
person_properties,
|
222
|
+
group_properties,
|
223
|
+
only_evaluate_locally
|
224
|
+
)
|
225
|
+
|
175
226
|
response[:featureFlags]
|
176
227
|
end
|
177
228
|
|
178
|
-
def get_all_flags_and_payloads(
|
229
|
+
def get_all_flags_and_payloads(
|
230
|
+
distinct_id,
|
231
|
+
groups = {},
|
232
|
+
person_properties = {},
|
233
|
+
group_properties = {},
|
234
|
+
only_evaluate_locally = false,
|
235
|
+
raise_on_error = false
|
236
|
+
)
|
179
237
|
load_feature_flags
|
180
238
|
|
181
239
|
flags = {}
|
@@ -188,27 +246,26 @@ class PostHog
|
|
188
246
|
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
|
189
247
|
flags[flag[:key]] = match_value
|
190
248
|
match_payload = _compute_flag_payload_locally(flag[:key], match_value)
|
191
|
-
if match_payload
|
192
|
-
|
193
|
-
end
|
194
|
-
rescue InconclusiveMatchError => e
|
249
|
+
payloads[flag[:key]] = match_payload if match_payload
|
250
|
+
rescue InconclusiveMatchError
|
195
251
|
fallback_to_server = true
|
196
252
|
rescue StandardError => e
|
197
253
|
@on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
|
198
254
|
fallback_to_server = true
|
199
255
|
end
|
200
256
|
end
|
257
|
+
|
201
258
|
if fallback_to_server && !only_evaluate_locally
|
202
259
|
begin
|
203
260
|
flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
|
204
261
|
|
205
262
|
unless flags_and_payloads.key?(:featureFlags)
|
206
|
-
raise StandardError
|
263
|
+
raise StandardError, "Error flags response: #{flags_and_payloads}"
|
207
264
|
end
|
208
265
|
|
209
266
|
# Check if feature_flags are quota limited
|
210
|
-
if flags_and_payloads[:quotaLimited]
|
211
|
-
logger.warn
|
267
|
+
if flags_and_payloads[:quotaLimited] && flags_and_payloads[:quotaLimited].include?('feature_flags')
|
268
|
+
logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
|
212
269
|
flags = {}
|
213
270
|
payloads = {}
|
214
271
|
else
|
@@ -221,46 +278,58 @@ class PostHog
|
|
221
278
|
raise if raise_on_error
|
222
279
|
end
|
223
280
|
end
|
224
|
-
|
281
|
+
|
282
|
+
{
|
283
|
+
featureFlags: flags,
|
284
|
+
featureFlagPayloads: payloads,
|
285
|
+
requestId: request_id
|
286
|
+
}
|
225
287
|
end
|
226
288
|
|
227
|
-
def get_feature_flag_payload(
|
228
|
-
|
289
|
+
def get_feature_flag_payload(
|
290
|
+
key,
|
291
|
+
distinct_id,
|
292
|
+
match_value = nil,
|
293
|
+
groups = {},
|
294
|
+
person_properties = {},
|
295
|
+
group_properties = {},
|
296
|
+
only_evaluate_locally = false
|
297
|
+
)
|
298
|
+
if match_value.nil?
|
229
299
|
match_value = get_feature_flag(
|
230
300
|
key,
|
231
301
|
distinct_id,
|
232
302
|
groups,
|
233
303
|
person_properties,
|
234
304
|
group_properties,
|
235
|
-
true
|
305
|
+
true
|
236
306
|
)[0]
|
237
307
|
end
|
238
308
|
response = nil
|
239
|
-
|
240
|
-
|
241
|
-
end
|
242
|
-
if response == nil and !only_evaluate_locally
|
309
|
+
response = _compute_flag_payload_locally(key, match_value) unless match_value.nil?
|
310
|
+
if response.nil? && !only_evaluate_locally
|
243
311
|
flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
244
312
|
response = flags_payloads[key.downcase] || nil
|
245
313
|
end
|
246
314
|
response
|
247
315
|
end
|
248
316
|
|
249
|
-
def shutdown_poller
|
317
|
+
def shutdown_poller
|
250
318
|
@task.shutdown
|
251
319
|
end
|
252
320
|
|
253
321
|
# Class methods
|
254
322
|
|
255
323
|
def self.compare(lhs, rhs, operator)
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
324
|
+
case operator
|
325
|
+
when 'gt'
|
326
|
+
lhs > rhs
|
327
|
+
when 'gte'
|
328
|
+
lhs >= rhs
|
329
|
+
when 'lt'
|
330
|
+
lhs < rhs
|
331
|
+
when 'lte'
|
332
|
+
lhs <= rhs
|
264
333
|
else
|
265
334
|
raise "Invalid operator: #{operator}"
|
266
335
|
end
|
@@ -269,34 +338,33 @@ class PostHog
|
|
269
338
|
def self.relative_date_parse_for_feature_flag_matching(value)
|
270
339
|
match = /^-?([0-9]+)([a-z])$/.match(value)
|
271
340
|
parsed_dt = DateTime.now.new_offset(0)
|
272
|
-
|
273
|
-
number = match[1].to_i
|
341
|
+
return unless match
|
274
342
|
|
275
|
-
|
276
|
-
# Guard against overflow, disallow numbers greater than 10_000
|
277
|
-
return nil
|
278
|
-
end
|
343
|
+
number = match[1].to_i
|
279
344
|
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
parsed_dt
|
345
|
+
if number >= 10_000
|
346
|
+
# Guard against overflow, disallow numbers greater than 10_000
|
347
|
+
return nil
|
348
|
+
end
|
349
|
+
|
350
|
+
interval = match[2]
|
351
|
+
case interval
|
352
|
+
when 'h'
|
353
|
+
parsed_dt -= (number / 24.0)
|
354
|
+
when 'd'
|
355
|
+
parsed_dt = parsed_dt.prev_day(number)
|
356
|
+
when 'w'
|
357
|
+
parsed_dt = parsed_dt.prev_day(number * 7)
|
358
|
+
when 'm'
|
359
|
+
parsed_dt = parsed_dt.prev_month(number)
|
360
|
+
when 'y'
|
361
|
+
parsed_dt = parsed_dt.prev_year(number)
|
295
362
|
else
|
296
|
-
nil
|
363
|
+
return nil
|
297
364
|
end
|
298
|
-
end
|
299
365
|
|
366
|
+
parsed_dt
|
367
|
+
end
|
300
368
|
|
301
369
|
def self.match_property(property, property_values)
|
302
370
|
# only looks for matches where key exists in property_values
|
@@ -310,9 +378,9 @@ class PostHog
|
|
310
378
|
operator = property[:operator] || 'exact'
|
311
379
|
|
312
380
|
if !property_values.key?(key)
|
313
|
-
raise InconclusiveMatchError
|
381
|
+
raise InconclusiveMatchError, "Property #{key} not found in property_values"
|
314
382
|
elsif operator == 'is_not_set'
|
315
|
-
raise InconclusiveMatchError
|
383
|
+
raise InconclusiveMatchError, 'Operator is_not_set not supported'
|
316
384
|
end
|
317
385
|
|
318
386
|
override_value = property_values[key]
|
@@ -321,11 +389,10 @@ class PostHog
|
|
321
389
|
when 'exact', 'is_not'
|
322
390
|
if value.is_a?(Array)
|
323
391
|
values_stringified = value.map { |val| val.to_s.downcase }
|
324
|
-
if operator == 'exact'
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
end
|
392
|
+
return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'
|
393
|
+
|
394
|
+
return values_stringified.none?(override_value.to_s.downcase)
|
395
|
+
|
329
396
|
end
|
330
397
|
if operator == 'exact'
|
331
398
|
value.to_s.downcase == override_value.to_s.downcase
|
@@ -346,74 +413,68 @@ class PostHog
|
|
346
413
|
parsed_value = nil
|
347
414
|
begin
|
348
415
|
parsed_value = Float(value)
|
349
|
-
rescue StandardError
|
416
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
350
417
|
end
|
351
418
|
if !parsed_value.nil? && !override_value.nil?
|
352
419
|
if override_value.is_a?(String)
|
353
|
-
|
420
|
+
compare(override_value, value.to_s, operator)
|
354
421
|
else
|
355
|
-
|
422
|
+
compare(override_value, parsed_value, operator)
|
356
423
|
end
|
357
424
|
else
|
358
|
-
|
425
|
+
compare(override_value.to_s, value.to_s, operator)
|
359
426
|
end
|
360
427
|
when 'is_date_before', 'is_date_after'
|
361
428
|
override_date = PostHog::Utils.convert_to_datetime(override_value.to_s)
|
362
|
-
parsed_date =
|
429
|
+
parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
|
363
430
|
|
364
|
-
if parsed_date.nil?
|
365
|
-
|
366
|
-
|
431
|
+
parsed_date = PostHog::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
|
432
|
+
|
433
|
+
raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
|
367
434
|
|
368
|
-
if !parsed_date
|
369
|
-
raise InconclusiveMatchError.new("Invalid date format")
|
370
|
-
end
|
371
435
|
if operator == 'is_date_before'
|
372
|
-
|
436
|
+
override_date < parsed_date
|
373
437
|
elsif operator == 'is_date_after'
|
374
|
-
|
438
|
+
override_date > parsed_date
|
375
439
|
end
|
376
440
|
else
|
377
|
-
raise InconclusiveMatchError
|
441
|
+
raise InconclusiveMatchError, "Unknown operator: #{operator}"
|
378
442
|
end
|
379
443
|
end
|
380
444
|
|
381
445
|
private
|
382
446
|
|
383
447
|
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
384
|
-
if flag[:ensure_experience_continuity]
|
385
|
-
raise InconclusiveMatchError.new("Flag has experience continuity enabled")
|
386
|
-
end
|
448
|
+
raise InconclusiveMatchError, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
387
449
|
|
388
|
-
return false
|
450
|
+
return false unless flag[:active]
|
389
451
|
|
390
452
|
flag_filters = flag[:filters] || {}
|
391
453
|
|
392
454
|
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
393
|
-
if
|
394
|
-
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
455
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties) if aggregation_group_type_index.nil?
|
395
456
|
|
396
|
-
|
397
|
-
logger.warn "[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
398
|
-
# failover to `/flags/`
|
399
|
-
raise InconclusiveMatchError.new("Flag has unknown group type index")
|
400
|
-
end
|
457
|
+
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
401
458
|
|
402
|
-
|
459
|
+
if group_name.nil?
|
460
|
+
logger.warn(
|
461
|
+
"[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
462
|
+
)
|
463
|
+
# failover to `/flags/`
|
464
|
+
raise InconclusiveMatchError, 'Flag has unknown group type index'
|
465
|
+
end
|
403
466
|
|
404
|
-
|
405
|
-
# Group flags are never enabled if appropriate `groups` aren't passed in
|
406
|
-
# don't failover to `/flags/`, since response will be the same
|
407
|
-
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
408
|
-
return false
|
409
|
-
end
|
467
|
+
group_name_symbol = group_name.to_sym
|
410
468
|
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
469
|
+
unless groups.key?(group_name_symbol)
|
470
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
471
|
+
# don't failover to `/flags/`, since response will be the same
|
472
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
473
|
+
return false
|
415
474
|
end
|
416
475
|
|
476
|
+
focused_group_properties = group_properties[group_name_symbol]
|
477
|
+
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties)
|
417
478
|
end
|
418
479
|
|
419
480
|
def _compute_flag_payload_locally(key, match_value)
|
@@ -437,23 +498,27 @@ class PostHog
|
|
437
498
|
|
438
499
|
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
|
439
500
|
# evaluated first, and the variant override is applied to the first matching condition.
|
440
|
-
sorted_flag_conditions = flag_conditions.each_with_index.sort_by
|
501
|
+
sorted_flag_conditions = flag_conditions.each_with_index.sort_by do |condition, idx|
|
502
|
+
[condition[:variant].nil? ? 1 : -1, idx]
|
503
|
+
end
|
441
504
|
|
442
|
-
|
505
|
+
# NOTE: This NEEDS to be `each` because `each_key` breaks
|
506
|
+
# This is not a hash, it's just an array with 2 entries
|
507
|
+
sorted_flag_conditions.each do |condition, _idx| # rubocop:disable Style/HashEachMethods
|
443
508
|
begin
|
444
509
|
if is_condition_match(flag, distinct_id, condition, properties)
|
445
510
|
variant_override = condition[:variant]
|
446
511
|
flag_multivariate = flag_filters[:multivariate] || {}
|
447
512
|
flag_variants = flag_multivariate[:variants] || []
|
448
|
-
if flag_variants.map{|variant| variant[:key]}.include?(condition[:variant])
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
513
|
+
variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
|
514
|
+
variant_override
|
515
|
+
else
|
516
|
+
get_matching_variant(flag, distinct_id)
|
517
|
+
end
|
453
518
|
result = variant || true
|
454
519
|
break
|
455
520
|
end
|
456
|
-
rescue InconclusiveMatchError
|
521
|
+
rescue InconclusiveMatchError
|
457
522
|
is_inconclusive = true
|
458
523
|
end
|
459
524
|
end
|
@@ -461,47 +526,46 @@ class PostHog
|
|
461
526
|
if !result.nil?
|
462
527
|
return result
|
463
528
|
elsif is_inconclusive
|
464
|
-
raise InconclusiveMatchError
|
529
|
+
raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
|
465
530
|
end
|
466
531
|
|
467
532
|
# We can only return False when all conditions are False
|
468
|
-
|
533
|
+
false
|
469
534
|
end
|
470
535
|
|
471
|
-
|
536
|
+
# TODO: Rename to `condition_match?` in future version
|
537
|
+
def is_condition_match(flag, distinct_id, condition, properties) # rubocop:disable Naming/PredicateName
|
472
538
|
rollout_percentage = condition[:rollout_percentage]
|
473
539
|
|
474
|
-
|
475
|
-
if !condition[:properties].all?
|
476
|
-
|
477
|
-
|
540
|
+
unless (condition[:properties] || []).empty?
|
541
|
+
if !condition[:properties].all? do |prop|
|
542
|
+
FeatureFlagsPoller.match_property(prop, properties)
|
543
|
+
end
|
478
544
|
return false
|
479
545
|
elsif rollout_percentage.nil?
|
480
546
|
return true
|
481
547
|
end
|
482
548
|
end
|
483
549
|
|
484
|
-
if !rollout_percentage.nil?
|
485
|
-
return false
|
486
|
-
end
|
550
|
+
return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
|
487
551
|
|
488
|
-
|
552
|
+
true
|
489
553
|
end
|
490
554
|
|
491
555
|
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
492
556
|
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
493
557
|
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
494
558
|
# we can do _hash(key, distinct_id) < 0.2
|
495
|
-
def _hash(key, distinct_id, salt=
|
559
|
+
def _hash(key, distinct_id, salt = '')
|
496
560
|
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
497
|
-
|
561
|
+
(Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
498
562
|
end
|
499
563
|
|
500
564
|
def get_matching_variant(flag, distinct_id)
|
501
|
-
hash_value = _hash(flag[:key], distinct_id,
|
502
|
-
matching_variant = variant_lookup_table(flag).find
|
503
|
-
|
504
|
-
|
565
|
+
hash_value = _hash(flag[:key], distinct_id, 'variant')
|
566
|
+
matching_variant = variant_lookup_table(flag).find do |variant|
|
567
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
568
|
+
end
|
505
569
|
matching_variant.nil? ? nil : matching_variant[:key]
|
506
570
|
end
|
507
571
|
|
@@ -512,14 +576,14 @@ class PostHog
|
|
512
576
|
variants = flag_filters[:multivariate] || {}
|
513
577
|
multivariates = variants[:variants] || []
|
514
578
|
multivariates.each do |variant|
|
515
|
-
value_max = value_min + variant[:rollout_percentage].to_f / 100
|
516
|
-
lookup_table << {
|
579
|
+
value_max = value_min + (variant[:rollout_percentage].to_f / 100)
|
580
|
+
lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
|
517
581
|
value_min = value_max
|
518
582
|
end
|
519
|
-
|
583
|
+
lookup_table
|
520
584
|
end
|
521
585
|
|
522
|
-
def _load_feature_flags
|
586
|
+
def _load_feature_flags
|
523
587
|
begin
|
524
588
|
res = _request_feature_flag_definitions
|
525
589
|
rescue StandardError => e
|
@@ -529,7 +593,10 @@ class PostHog
|
|
529
593
|
|
530
594
|
# Handle quota limits with 402 status
|
531
595
|
if res.is_a?(Hash) && res[:status] == 402
|
532
|
-
logger.warn
|
596
|
+
logger.warn(
|
597
|
+
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
|
598
|
+
'Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
|
599
|
+
)
|
533
600
|
@feature_flags = Concurrent::Array.new
|
534
601
|
@feature_flags_by_key = {}
|
535
602
|
@group_type_mapping = Concurrent::Hash.new
|
@@ -538,22 +605,18 @@ class PostHog
|
|
538
605
|
return
|
539
606
|
end
|
540
607
|
|
541
|
-
if
|
542
|
-
logger.debug "Failed to load feature flags: #{res}"
|
543
|
-
else
|
608
|
+
if res.key?(:flags)
|
544
609
|
@feature_flags = res[:flags] || []
|
545
610
|
@feature_flags_by_key = {}
|
546
611
|
@feature_flags.each do |flag|
|
547
|
-
|
548
|
-
@feature_flags_by_key[flag[:key]] = flag
|
549
|
-
end
|
612
|
+
@feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
|
550
613
|
end
|
551
614
|
@group_type_mapping = res[:group_type_mapping] || {}
|
552
615
|
|
553
616
|
logger.debug "Loaded #{@feature_flags.length} feature flags"
|
554
|
-
if @loaded_flags_successfully_once.false?
|
555
|
-
|
556
|
-
|
617
|
+
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
|
618
|
+
else
|
619
|
+
logger.debug "Failed to load feature flags: #{res}"
|
557
620
|
end
|
558
621
|
end
|
559
622
|
|
@@ -565,7 +628,7 @@ class PostHog
|
|
565
628
|
_request(uri, req)
|
566
629
|
end
|
567
630
|
|
568
|
-
def _request_feature_flag_evaluation(data={})
|
631
|
+
def _request_feature_flag_evaluation(data = {})
|
569
632
|
uri = URI("#{@host}/flags/?v=2")
|
570
633
|
req = Net::HTTP::Post.new(uri)
|
571
634
|
req['Content-Type'] = 'application/json'
|
@@ -584,23 +647,29 @@ class PostHog
|
|
584
647
|
_request(uri, req, @feature_flag_request_timeout_seconds)
|
585
648
|
end
|
586
649
|
|
650
|
+
# rubocop:disable Lint/ShadowedException
|
587
651
|
def _request(uri, request_object, timeout = nil)
|
588
652
|
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
|
589
653
|
request_timeout = timeout || 10
|
590
654
|
|
591
655
|
begin
|
592
|
-
Net::HTTP.start(
|
656
|
+
Net::HTTP.start(
|
657
|
+
uri.hostname,
|
658
|
+
uri.port,
|
659
|
+
use_ssl: uri.scheme == 'https',
|
660
|
+
read_timeout: request_timeout
|
661
|
+
) do |http|
|
593
662
|
res = http.request(request_object)
|
594
|
-
|
663
|
+
|
595
664
|
# Parse response body to hash
|
596
665
|
begin
|
597
|
-
response = JSON.parse(res.body, {symbolize_names: true})
|
666
|
+
response = JSON.parse(res.body, { symbolize_names: true })
|
598
667
|
# Only add status if response is a hash
|
599
|
-
response = response.
|
668
|
+
response = response.merge({ status: res.code.to_i }) if response.is_a?(Hash)
|
600
669
|
return response
|
601
670
|
rescue JSON::ParserError
|
602
671
|
# Handle case when response isn't valid JSON
|
603
|
-
return {error:
|
672
|
+
return { error: 'Invalid JSON response', body: res.body, status: res.code.to_i }
|
604
673
|
end
|
605
674
|
end
|
606
675
|
rescue Timeout::Error,
|
@@ -611,10 +680,11 @@ class PostHog
|
|
611
680
|
Net::HTTPHeaderSyntaxError,
|
612
681
|
Net::ReadTimeout,
|
613
682
|
Net::WriteTimeout,
|
614
|
-
Net::ProtocolError
|
683
|
+
Net::ProtocolError
|
615
684
|
logger.debug("Unable to complete request to #{uri}")
|
616
685
|
raise
|
617
686
|
end
|
618
687
|
end
|
688
|
+
# rubocop:enable Lint/ShadowedException
|
619
689
|
end
|
620
690
|
end
|