posthog-rails 3.5.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/lib/posthog/backoff_policy.rb +46 -0
- data/lib/posthog/client.rb +545 -0
- data/lib/posthog/defaults.rb +44 -0
- data/lib/posthog/exception_capture.rb +116 -0
- data/lib/posthog/feature_flag.rb +66 -0
- data/lib/posthog/feature_flag_error.rb +36 -0
- data/lib/posthog/feature_flag_result.rb +56 -0
- data/lib/posthog/feature_flags.rb +1004 -0
- data/lib/posthog/field_parser.rb +194 -0
- data/lib/posthog/logging.rb +70 -0
- data/lib/posthog/message_batch.rb +73 -0
- data/lib/posthog/noop_worker.rb +19 -0
- data/lib/posthog/response.rb +15 -0
- data/lib/posthog/send_feature_flags_options.rb +34 -0
- data/lib/posthog/send_worker.rb +70 -0
- data/lib/posthog/transport.rb +144 -0
- data/lib/posthog/utils.rb +145 -0
- data/lib/posthog/version.rb +5 -0
- data/lib/posthog.rb +14 -0
- metadata +91 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'posthog/version'
|
|
7
|
+
require 'posthog/logging'
|
|
8
|
+
require 'posthog/feature_flag'
|
|
9
|
+
require 'digest'
|
|
10
|
+
|
|
11
|
+
module PostHog
|
|
12
|
+
class InconclusiveMatchError < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# RequiresServerEvaluation is raised when feature flag evaluation requires
|
|
16
|
+
# server-side data that is not available locally (e.g., static cohorts,
|
|
17
|
+
# experience continuity). This error should propagate immediately to trigger
|
|
18
|
+
# API fallback, unlike InconclusiveMatchError which allows trying other conditions.
|
|
19
|
+
class RequiresServerEvaluation < StandardError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class FeatureFlagsPoller
|
|
23
|
+
include PostHog::Logging
|
|
24
|
+
include PostHog::Utils
|
|
25
|
+
|
|
26
|
+
def initialize(
|
|
27
|
+
polling_interval,
|
|
28
|
+
personal_api_key,
|
|
29
|
+
project_api_key,
|
|
30
|
+
host,
|
|
31
|
+
feature_flag_request_timeout_seconds,
|
|
32
|
+
on_error = nil
|
|
33
|
+
)
|
|
34
|
+
@polling_interval = polling_interval || 30
|
|
35
|
+
@personal_api_key = personal_api_key
|
|
36
|
+
@project_api_key = project_api_key
|
|
37
|
+
@host = host
|
|
38
|
+
@feature_flags = Concurrent::Array.new
|
|
39
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
40
|
+
@cohorts = Concurrent::Hash.new
|
|
41
|
+
@loaded_flags_successfully_once = Concurrent::AtomicBoolean.new
|
|
42
|
+
@feature_flags_by_key = nil
|
|
43
|
+
@feature_flag_request_timeout_seconds = feature_flag_request_timeout_seconds
|
|
44
|
+
@on_error = on_error || proc { |status, error| }
|
|
45
|
+
@quota_limited = Concurrent::AtomicBoolean.new(false)
|
|
46
|
+
@flags_etag = Concurrent::AtomicReference.new(nil)
|
|
47
|
+
@task =
|
|
48
|
+
Concurrent::TimerTask.new(
|
|
49
|
+
execution_interval: polling_interval
|
|
50
|
+
) { _load_feature_flags }
|
|
51
|
+
|
|
52
|
+
# If no personal API key, disable local evaluation & thus polling for definitions
|
|
53
|
+
if @personal_api_key.nil?
|
|
54
|
+
logger.info 'No personal API key provided, disabling local evaluation'
|
|
55
|
+
@loaded_flags_successfully_once.make_true
|
|
56
|
+
else
|
|
57
|
+
# load once before timer
|
|
58
|
+
load_feature_flags
|
|
59
|
+
@task.execute
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load_feature_flags(force_reload = false)
|
|
64
|
+
return unless @loaded_flags_successfully_once.false? || force_reload
|
|
65
|
+
|
|
66
|
+
_load_feature_flags
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get_feature_variants(
|
|
70
|
+
distinct_id,
|
|
71
|
+
groups = {},
|
|
72
|
+
person_properties = {},
|
|
73
|
+
group_properties = {},
|
|
74
|
+
only_evaluate_locally = false,
|
|
75
|
+
raise_on_error = false
|
|
76
|
+
)
|
|
77
|
+
# TODO: Convert to options hash for easier argument passing
|
|
78
|
+
flags_data = get_all_flags_and_payloads(
|
|
79
|
+
distinct_id,
|
|
80
|
+
groups,
|
|
81
|
+
person_properties,
|
|
82
|
+
group_properties,
|
|
83
|
+
only_evaluate_locally,
|
|
84
|
+
raise_on_error
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if flags_data.key?(:featureFlags)
|
|
88
|
+
stringify_keys(flags_data[:featureFlags] || {})
|
|
89
|
+
else
|
|
90
|
+
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def get_feature_payloads(
|
|
96
|
+
distinct_id,
|
|
97
|
+
groups = {},
|
|
98
|
+
person_properties = {},
|
|
99
|
+
group_properties = {},
|
|
100
|
+
_only_evaluate_locally = false
|
|
101
|
+
)
|
|
102
|
+
flags_data = get_all_flags_and_payloads(
|
|
103
|
+
distinct_id,
|
|
104
|
+
groups,
|
|
105
|
+
person_properties,
|
|
106
|
+
group_properties
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if flags_data.key?(:featureFlagPayloads)
|
|
110
|
+
stringify_keys(flags_data[:featureFlagPayloads] || {})
|
|
111
|
+
else
|
|
112
|
+
logger.debug "Missing feature flag payloads key: #{flags_data.to_json}"
|
|
113
|
+
{}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get_flags(distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
|
118
|
+
request_data = {
|
|
119
|
+
distinct_id: distinct_id,
|
|
120
|
+
groups: groups,
|
|
121
|
+
person_properties: person_properties,
|
|
122
|
+
group_properties: group_properties
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
flags_response = _request_feature_flag_evaluation(request_data)
|
|
126
|
+
|
|
127
|
+
# Only normalize if we have flags in the response
|
|
128
|
+
if flags_response[:flags]
|
|
129
|
+
# v4 format
|
|
130
|
+
flags_hash = flags_response[:flags].transform_values do |flag|
|
|
131
|
+
FeatureFlag.new(flag)
|
|
132
|
+
end
|
|
133
|
+
flags_response[:flags] = flags_hash
|
|
134
|
+
flags_response[:featureFlags] = flags_hash.transform_values(&:get_value).transform_keys(&:to_sym)
|
|
135
|
+
flags_response[:featureFlagPayloads] = flags_hash.transform_values(&:payload).transform_keys(&:to_sym)
|
|
136
|
+
elsif flags_response[:featureFlags]
|
|
137
|
+
# v3 format
|
|
138
|
+
flags_response[:featureFlags] = flags_response[:featureFlags] || {}
|
|
139
|
+
flags_response[:featureFlagPayloads] = flags_response[:featureFlagPayloads] || {}
|
|
140
|
+
flags_response[:flags] = flags_response[:featureFlags].to_h do |key, value|
|
|
141
|
+
[key, FeatureFlag.from_value_and_payload(key, value, flags_response[:featureFlagPayloads][key])]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
flags_response
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def get_remote_config_payload(flag_key)
|
|
149
|
+
_request_remote_config_payload(flag_key)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_feature_flag(
|
|
153
|
+
key,
|
|
154
|
+
distinct_id,
|
|
155
|
+
groups = {},
|
|
156
|
+
person_properties = {},
|
|
157
|
+
group_properties = {},
|
|
158
|
+
only_evaluate_locally = false
|
|
159
|
+
)
|
|
160
|
+
# make sure they're loaded on first run
|
|
161
|
+
load_feature_flags
|
|
162
|
+
|
|
163
|
+
symbolize_keys! groups
|
|
164
|
+
symbolize_keys! person_properties
|
|
165
|
+
symbolize_keys! group_properties
|
|
166
|
+
|
|
167
|
+
group_properties.each_value do |value|
|
|
168
|
+
symbolize_keys!(value)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
response = nil
|
|
172
|
+
payload = nil
|
|
173
|
+
feature_flag = @feature_flags_by_key&.[](key)
|
|
174
|
+
|
|
175
|
+
unless feature_flag.nil?
|
|
176
|
+
begin
|
|
177
|
+
response = _compute_flag_locally(feature_flag, distinct_id, groups, person_properties, group_properties)
|
|
178
|
+
payload = _compute_flag_payload_locally(key, response) unless response.nil?
|
|
179
|
+
logger.debug "Successfully computed flag locally: #{key} -> #{response}"
|
|
180
|
+
rescue RequiresServerEvaluation, InconclusiveMatchError => e
|
|
181
|
+
logger.debug "Failed to compute flag #{key} locally: #{e}"
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
@on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")}")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
flag_was_locally_evaluated = !response.nil?
|
|
188
|
+
|
|
189
|
+
request_id = nil
|
|
190
|
+
evaluated_at = nil
|
|
191
|
+
feature_flag_error = nil
|
|
192
|
+
|
|
193
|
+
if !flag_was_locally_evaluated && !only_evaluate_locally
|
|
194
|
+
begin
|
|
195
|
+
errors = []
|
|
196
|
+
flags_data = get_all_flags_and_payloads(distinct_id, groups, person_properties, group_properties,
|
|
197
|
+
only_evaluate_locally, true)
|
|
198
|
+
if flags_data.key?(:featureFlags)
|
|
199
|
+
flags = stringify_keys(flags_data[:featureFlags] || {})
|
|
200
|
+
payloads = stringify_keys(flags_data[:featureFlagPayloads] || {})
|
|
201
|
+
request_id = flags_data[:requestId]
|
|
202
|
+
evaluated_at = flags_data[:evaluatedAt]
|
|
203
|
+
else
|
|
204
|
+
logger.debug "Missing feature flags key: #{flags_data.to_json}"
|
|
205
|
+
flags = {}
|
|
206
|
+
payloads = {}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
status = flags_data[:status]
|
|
210
|
+
errors << FeatureFlagError.api_error(status) if status && status >= 400
|
|
211
|
+
errors << FeatureFlagError::ERRORS_WHILE_COMPUTING if flags_data[:errorsWhileComputingFlags]
|
|
212
|
+
errors << FeatureFlagError::QUOTA_LIMITED if flags_data[:quotaLimited]&.include?('feature_flags')
|
|
213
|
+
errors << FeatureFlagError::FLAG_MISSING unless flags.key?(key.to_s)
|
|
214
|
+
|
|
215
|
+
response = flags[key]
|
|
216
|
+
response = false if response.nil?
|
|
217
|
+
payload = payloads[key]
|
|
218
|
+
feature_flag_error = errors.join(',') unless errors.empty?
|
|
219
|
+
|
|
220
|
+
logger.debug "Successfully computed flag remotely: #{key} -> #{response}"
|
|
221
|
+
rescue Timeout::Error => e
|
|
222
|
+
@on_error.call(-1, "Timeout while fetching flags remotely: #{e}")
|
|
223
|
+
feature_flag_error = FeatureFlagError::TIMEOUT
|
|
224
|
+
rescue Errno::ECONNRESET, Errno::ECONNREFUSED, EOFError, SocketError => e
|
|
225
|
+
@on_error.call(-1, "Connection error while fetching flags remotely: #{e}")
|
|
226
|
+
feature_flag_error = FeatureFlagError::CONNECTION_ERROR
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
@on_error.call(-1, "Error computing flag remotely: #{e}. #{e.backtrace.join("\n")}")
|
|
229
|
+
feature_flag_error = FeatureFlagError::UNKNOWN_ERROR
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
[response, flag_was_locally_evaluated, request_id, evaluated_at, feature_flag_error, payload]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def get_all_flags(
|
|
237
|
+
distinct_id,
|
|
238
|
+
groups = {},
|
|
239
|
+
person_properties = {},
|
|
240
|
+
group_properties = {},
|
|
241
|
+
only_evaluate_locally = false
|
|
242
|
+
)
|
|
243
|
+
if @quota_limited.true?
|
|
244
|
+
logger.debug 'Not fetching flags from server - quota limited'
|
|
245
|
+
return {}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# returns a string hash of all flags
|
|
249
|
+
response = get_all_flags_and_payloads(
|
|
250
|
+
distinct_id,
|
|
251
|
+
groups,
|
|
252
|
+
person_properties,
|
|
253
|
+
group_properties,
|
|
254
|
+
only_evaluate_locally
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
response[:featureFlags]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def get_all_flags_and_payloads(
|
|
261
|
+
distinct_id,
|
|
262
|
+
groups = {},
|
|
263
|
+
person_properties = {},
|
|
264
|
+
group_properties = {},
|
|
265
|
+
only_evaluate_locally = false,
|
|
266
|
+
raise_on_error = false
|
|
267
|
+
)
|
|
268
|
+
load_feature_flags
|
|
269
|
+
|
|
270
|
+
flags = {}
|
|
271
|
+
payloads = {}
|
|
272
|
+
fallback_to_server = @feature_flags.empty?
|
|
273
|
+
request_id = nil # Only for /flags requests
|
|
274
|
+
evaluated_at = nil # Only for /flags requests
|
|
275
|
+
|
|
276
|
+
@feature_flags.each do |flag|
|
|
277
|
+
match_value = _compute_flag_locally(flag, distinct_id, groups, person_properties, group_properties)
|
|
278
|
+
flags[flag[:key]] = match_value
|
|
279
|
+
match_payload = _compute_flag_payload_locally(flag[:key], match_value)
|
|
280
|
+
payloads[flag[:key]] = match_payload if match_payload
|
|
281
|
+
rescue RequiresServerEvaluation, InconclusiveMatchError
|
|
282
|
+
fallback_to_server = true
|
|
283
|
+
rescue StandardError => e
|
|
284
|
+
@on_error.call(-1, "Error computing flag locally: #{e}. #{e.backtrace.join("\n")} ")
|
|
285
|
+
fallback_to_server = true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
errors_while_computing = false
|
|
289
|
+
quota_limited = nil
|
|
290
|
+
status_code = nil
|
|
291
|
+
|
|
292
|
+
if fallback_to_server && !only_evaluate_locally
|
|
293
|
+
begin
|
|
294
|
+
flags_and_payloads = get_flags(distinct_id, groups, person_properties, group_properties)
|
|
295
|
+
errors_while_computing = flags_and_payloads[:errorsWhileComputingFlags] || false
|
|
296
|
+
quota_limited = flags_and_payloads[:quotaLimited]
|
|
297
|
+
status_code = flags_and_payloads[:status]
|
|
298
|
+
|
|
299
|
+
unless flags_and_payloads.key?(:featureFlags)
|
|
300
|
+
raise StandardError, "Error flags response: #{flags_and_payloads}"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
request_id = flags_and_payloads[:requestId]
|
|
304
|
+
evaluated_at = flags_and_payloads[:evaluatedAt]
|
|
305
|
+
|
|
306
|
+
# Check if feature_flags are quota limited
|
|
307
|
+
if quota_limited&.include?('feature_flags')
|
|
308
|
+
logger.warn '[FEATURE FLAGS] Quota limited for feature flags'
|
|
309
|
+
flags = {}
|
|
310
|
+
payloads = {}
|
|
311
|
+
else
|
|
312
|
+
flags = stringify_keys(flags_and_payloads[:featureFlags] || {})
|
|
313
|
+
payloads = stringify_keys(flags_and_payloads[:featureFlagPayloads] || {})
|
|
314
|
+
end
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
@on_error.call(-1, "Error computing flag remotely: #{e}")
|
|
317
|
+
raise if raise_on_error
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
{
|
|
322
|
+
featureFlags: flags,
|
|
323
|
+
featureFlagPayloads: payloads,
|
|
324
|
+
requestId: request_id,
|
|
325
|
+
evaluatedAt: evaluated_at,
|
|
326
|
+
errorsWhileComputingFlags: errors_while_computing,
|
|
327
|
+
quotaLimited: quota_limited,
|
|
328
|
+
status: status_code
|
|
329
|
+
}
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def get_feature_flag_payload(
|
|
333
|
+
key,
|
|
334
|
+
distinct_id,
|
|
335
|
+
match_value = nil,
|
|
336
|
+
groups = {},
|
|
337
|
+
person_properties = {},
|
|
338
|
+
group_properties = {},
|
|
339
|
+
only_evaluate_locally = false
|
|
340
|
+
)
|
|
341
|
+
if match_value.nil?
|
|
342
|
+
match_value = get_feature_flag(
|
|
343
|
+
key,
|
|
344
|
+
distinct_id,
|
|
345
|
+
groups,
|
|
346
|
+
person_properties,
|
|
347
|
+
group_properties,
|
|
348
|
+
true
|
|
349
|
+
)[0]
|
|
350
|
+
end
|
|
351
|
+
response = nil
|
|
352
|
+
response = _compute_flag_payload_locally(key, match_value) unless match_value.nil?
|
|
353
|
+
if response.nil? && !only_evaluate_locally
|
|
354
|
+
flags_payloads = get_feature_payloads(distinct_id, groups, person_properties, group_properties)
|
|
355
|
+
response = flags_payloads[key.downcase] || nil
|
|
356
|
+
end
|
|
357
|
+
response
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def shutdown_poller
|
|
361
|
+
@task.shutdown
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Class methods
|
|
365
|
+
|
|
366
|
+
def self.compare(lhs, rhs, operator)
|
|
367
|
+
case operator
|
|
368
|
+
when 'gt'
|
|
369
|
+
lhs > rhs
|
|
370
|
+
when 'gte'
|
|
371
|
+
lhs >= rhs
|
|
372
|
+
when 'lt'
|
|
373
|
+
lhs < rhs
|
|
374
|
+
when 'lte'
|
|
375
|
+
lhs <= rhs
|
|
376
|
+
else
|
|
377
|
+
raise "Invalid operator: #{operator}"
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def self.relative_date_parse_for_feature_flag_matching(value)
|
|
382
|
+
match = /^-?([0-9]+)([a-z])$/.match(value)
|
|
383
|
+
parsed_dt = DateTime.now.new_offset(0)
|
|
384
|
+
return unless match
|
|
385
|
+
|
|
386
|
+
number = match[1].to_i
|
|
387
|
+
|
|
388
|
+
if number >= 10_000
|
|
389
|
+
# Guard against overflow, disallow numbers greater than 10_000
|
|
390
|
+
return nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
interval = match[2]
|
|
394
|
+
case interval
|
|
395
|
+
when 'h'
|
|
396
|
+
parsed_dt -= (number / 24.0)
|
|
397
|
+
when 'd'
|
|
398
|
+
parsed_dt = parsed_dt.prev_day(number)
|
|
399
|
+
when 'w'
|
|
400
|
+
parsed_dt = parsed_dt.prev_day(number * 7)
|
|
401
|
+
when 'm'
|
|
402
|
+
parsed_dt = parsed_dt.prev_month(number)
|
|
403
|
+
when 'y'
|
|
404
|
+
parsed_dt = parsed_dt.prev_year(number)
|
|
405
|
+
else
|
|
406
|
+
return nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
parsed_dt
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def self.match_property(property, property_values, cohort_properties = {})
|
|
413
|
+
# only looks for matches where key exists in property_values
|
|
414
|
+
# doesn't support operator is_not_set
|
|
415
|
+
|
|
416
|
+
PostHog::Utils.symbolize_keys! property
|
|
417
|
+
PostHog::Utils.symbolize_keys! property_values
|
|
418
|
+
|
|
419
|
+
# Handle cohort properties
|
|
420
|
+
return match_cohort(property, property_values, cohort_properties) if extract_value(property, :type) == 'cohort'
|
|
421
|
+
|
|
422
|
+
key = property[:key].to_sym
|
|
423
|
+
value = property[:value]
|
|
424
|
+
operator = property[:operator] || 'exact'
|
|
425
|
+
|
|
426
|
+
if !property_values.key?(key)
|
|
427
|
+
raise InconclusiveMatchError, "Property #{key} not found in property_values"
|
|
428
|
+
elsif operator == 'is_not_set'
|
|
429
|
+
raise InconclusiveMatchError, 'Operator is_not_set not supported'
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
override_value = property_values[key]
|
|
433
|
+
|
|
434
|
+
case operator
|
|
435
|
+
when 'exact', 'is_not'
|
|
436
|
+
if value.is_a?(Array)
|
|
437
|
+
values_stringified = value.map { |val| val.to_s.downcase }
|
|
438
|
+
return values_stringified.any?(override_value.to_s.downcase) if operator == 'exact'
|
|
439
|
+
|
|
440
|
+
return values_stringified.none?(override_value.to_s.downcase)
|
|
441
|
+
|
|
442
|
+
end
|
|
443
|
+
if operator == 'exact'
|
|
444
|
+
value.to_s.downcase == override_value.to_s.downcase
|
|
445
|
+
else
|
|
446
|
+
value.to_s.downcase != override_value.to_s.downcase
|
|
447
|
+
end
|
|
448
|
+
when 'is_set'
|
|
449
|
+
property_values.key?(key)
|
|
450
|
+
when 'icontains'
|
|
451
|
+
override_value.to_s.downcase.include?(value.to_s.downcase)
|
|
452
|
+
when 'not_icontains'
|
|
453
|
+
!override_value.to_s.downcase.include?(value.to_s.downcase)
|
|
454
|
+
when 'regex'
|
|
455
|
+
PostHog::Utils.is_valid_regex(value.to_s) && !Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
456
|
+
when 'not_regex'
|
|
457
|
+
PostHog::Utils.is_valid_regex(value.to_s) && Regexp.new(value.to_s).match(override_value.to_s).nil?
|
|
458
|
+
when 'gt', 'gte', 'lt', 'lte'
|
|
459
|
+
parsed_value = nil
|
|
460
|
+
begin
|
|
461
|
+
parsed_value = Float(value)
|
|
462
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
463
|
+
end
|
|
464
|
+
if !parsed_value.nil? && !override_value.nil?
|
|
465
|
+
if override_value.is_a?(String)
|
|
466
|
+
compare(override_value, value.to_s, operator)
|
|
467
|
+
else
|
|
468
|
+
compare(override_value, parsed_value, operator)
|
|
469
|
+
end
|
|
470
|
+
else
|
|
471
|
+
compare(override_value.to_s, value.to_s, operator)
|
|
472
|
+
end
|
|
473
|
+
when 'is_date_before', 'is_date_after'
|
|
474
|
+
override_date = PostHog::Utils.convert_to_datetime(override_value.to_s)
|
|
475
|
+
parsed_date = relative_date_parse_for_feature_flag_matching(value.to_s)
|
|
476
|
+
|
|
477
|
+
parsed_date = PostHog::Utils.convert_to_datetime(value.to_s) if parsed_date.nil?
|
|
478
|
+
|
|
479
|
+
raise InconclusiveMatchError, 'Invalid date format' unless parsed_date
|
|
480
|
+
|
|
481
|
+
if operator == 'is_date_before'
|
|
482
|
+
override_date < parsed_date
|
|
483
|
+
elsif operator == 'is_date_after'
|
|
484
|
+
override_date > parsed_date
|
|
485
|
+
end
|
|
486
|
+
else
|
|
487
|
+
raise InconclusiveMatchError, "Unknown operator: #{operator}"
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def self.match_cohort(property, property_values, cohort_properties)
|
|
492
|
+
# Cohort properties are in the form of property groups like this:
|
|
493
|
+
# {
|
|
494
|
+
# "cohort_id" => {
|
|
495
|
+
# "type" => "AND|OR",
|
|
496
|
+
# "values" => [{
|
|
497
|
+
# "key" => "property_name", "value" => "property_value"
|
|
498
|
+
# }]
|
|
499
|
+
# }
|
|
500
|
+
# }
|
|
501
|
+
cohort_id = extract_value(property, :value).to_s
|
|
502
|
+
property_group = find_cohort_property(cohort_properties, cohort_id)
|
|
503
|
+
|
|
504
|
+
unless property_group
|
|
505
|
+
raise RequiresServerEvaluation,
|
|
506
|
+
"cohort #{cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
match_property_group(property_group, property_values, cohort_properties)
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def self.match_property_group(property_group, property_values, cohort_properties)
|
|
513
|
+
return true if property_group.nil? || property_group.empty?
|
|
514
|
+
|
|
515
|
+
group_type = extract_value(property_group, :type)
|
|
516
|
+
properties = extract_value(property_group, :values)
|
|
517
|
+
|
|
518
|
+
return true if properties.nil? || properties.empty?
|
|
519
|
+
|
|
520
|
+
if nested_property_group?(properties)
|
|
521
|
+
match_nested_property_group(properties, group_type, property_values, cohort_properties)
|
|
522
|
+
else
|
|
523
|
+
match_regular_property_group(properties, group_type, property_values, cohort_properties)
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def self.extract_value(hash, key)
|
|
528
|
+
return nil unless hash.is_a?(Hash)
|
|
529
|
+
|
|
530
|
+
hash[key] || hash[key.to_s]
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def self.find_cohort_property(cohort_properties, cohort_id)
|
|
534
|
+
return nil unless cohort_properties.is_a?(Hash)
|
|
535
|
+
|
|
536
|
+
cohort_properties[cohort_id] || cohort_properties[cohort_id.to_sym]
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def self.nested_property_group?(properties)
|
|
540
|
+
return false unless properties&.any?
|
|
541
|
+
|
|
542
|
+
first_property = properties[0]
|
|
543
|
+
return false unless first_property.is_a?(Hash)
|
|
544
|
+
|
|
545
|
+
first_property.key?(:values) || first_property.key?('values')
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def self.match_nested_property_group(properties, group_type, property_values, cohort_properties)
|
|
549
|
+
case group_type
|
|
550
|
+
when 'AND'
|
|
551
|
+
properties.each do |property|
|
|
552
|
+
return false unless match_property_group(property, property_values, cohort_properties)
|
|
553
|
+
end
|
|
554
|
+
true
|
|
555
|
+
when 'OR'
|
|
556
|
+
properties.each do |property|
|
|
557
|
+
return true if match_property_group(property, property_values, cohort_properties)
|
|
558
|
+
end
|
|
559
|
+
false
|
|
560
|
+
else
|
|
561
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}"
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def self.match_regular_property_group(properties, group_type, property_values, cohort_properties)
|
|
566
|
+
# Validate group type upfront
|
|
567
|
+
raise InconclusiveMatchError, "Unknown property group type: #{group_type}" unless %w[AND OR].include?(group_type)
|
|
568
|
+
|
|
569
|
+
error_matching_locally = false
|
|
570
|
+
|
|
571
|
+
properties.each do |prop|
|
|
572
|
+
PostHog::Utils.symbolize_keys!(prop)
|
|
573
|
+
|
|
574
|
+
matches = match_property(prop, property_values, cohort_properties)
|
|
575
|
+
|
|
576
|
+
negated = prop[:negation] || false
|
|
577
|
+
final_result = negated ? !matches : matches
|
|
578
|
+
|
|
579
|
+
# Short-circuit based on group type
|
|
580
|
+
if group_type == 'AND'
|
|
581
|
+
return false unless final_result
|
|
582
|
+
elsif final_result # group_type == 'OR'
|
|
583
|
+
return true
|
|
584
|
+
end
|
|
585
|
+
rescue RequiresServerEvaluation
|
|
586
|
+
# Immediately propagate - this condition requires server-side data
|
|
587
|
+
raise
|
|
588
|
+
rescue InconclusiveMatchError => e
|
|
589
|
+
PostHog::Logging.logger&.debug("Failed to compute property #{prop} locally: #{e}")
|
|
590
|
+
error_matching_locally = true
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
raise InconclusiveMatchError, "can't match cohort without a given cohort property value" if error_matching_locally
|
|
594
|
+
|
|
595
|
+
# If we reach here, return default based on group type
|
|
596
|
+
group_type == 'AND'
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Evaluates a flag dependency property according to the dependency chain algorithm.
|
|
600
|
+
#
|
|
601
|
+
# @param property [Hash] Flag property with type="flag" and dependency_chain
|
|
602
|
+
# @param evaluation_cache [Hash] Cache for storing evaluation results
|
|
603
|
+
# @param distinct_id [String] The distinct ID being evaluated
|
|
604
|
+
# @param properties [Hash] Person properties for evaluation
|
|
605
|
+
# @param cohort_properties [Hash] Cohort properties for evaluation
|
|
606
|
+
# @return [Boolean] True if all dependencies in the chain evaluate to true, false otherwise
|
|
607
|
+
def evaluate_flag_dependency(property, evaluation_cache, distinct_id, properties, cohort_properties)
|
|
608
|
+
if property[:operator] != 'flag_evaluates_to'
|
|
609
|
+
# Should never happen, but just in case
|
|
610
|
+
raise InconclusiveMatchError, "Operator #{property[:operator]} not supported for flag dependencies"
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
if @feature_flags_by_key.nil? || evaluation_cache.nil?
|
|
614
|
+
# Cannot evaluate flag dependencies without required context
|
|
615
|
+
raise InconclusiveMatchError,
|
|
616
|
+
"Cannot evaluate flag dependency on '#{property[:key] || 'unknown'}' " \
|
|
617
|
+
'without feature flags loaded or evaluation_cache'
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Check if dependency_chain is present - it should always be provided for flag dependencies
|
|
621
|
+
unless property.key?(:dependency_chain)
|
|
622
|
+
# Missing dependency_chain indicates malformed server data
|
|
623
|
+
raise InconclusiveMatchError,
|
|
624
|
+
"Flag dependency property for '#{property[:key] || 'unknown'}' " \
|
|
625
|
+
"is missing required 'dependency_chain' field"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
dependency_chain = property[:dependency_chain]
|
|
629
|
+
|
|
630
|
+
# Handle circular dependency (empty chain means circular)
|
|
631
|
+
if dependency_chain.empty?
|
|
632
|
+
PostHog::Logging.logger&.debug("Circular dependency detected for flag: #{property[:key]}")
|
|
633
|
+
raise InconclusiveMatchError,
|
|
634
|
+
"Circular dependency detected for flag '#{property[:key] || 'unknown'}'"
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# Evaluate all dependencies in the chain order
|
|
638
|
+
dependency_chain.each do |dep_flag_key|
|
|
639
|
+
unless evaluation_cache.key?(dep_flag_key)
|
|
640
|
+
# Need to evaluate this dependency first
|
|
641
|
+
dep_flag = @feature_flags_by_key[dep_flag_key]
|
|
642
|
+
if dep_flag.nil?
|
|
643
|
+
# Missing flag dependency - cannot evaluate locally
|
|
644
|
+
evaluation_cache[dep_flag_key] = nil
|
|
645
|
+
raise InconclusiveMatchError,
|
|
646
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}' - flag not found in local flags"
|
|
647
|
+
elsif !dep_flag[:active]
|
|
648
|
+
# Check if the flag is active (same check as in _compute_flag_locally)
|
|
649
|
+
evaluation_cache[dep_flag_key] = false
|
|
650
|
+
else
|
|
651
|
+
# Recursively evaluate the dependency using existing instance method
|
|
652
|
+
begin
|
|
653
|
+
dep_result = match_feature_flag_properties(
|
|
654
|
+
dep_flag,
|
|
655
|
+
distinct_id,
|
|
656
|
+
properties,
|
|
657
|
+
evaluation_cache,
|
|
658
|
+
cohort_properties
|
|
659
|
+
)
|
|
660
|
+
evaluation_cache[dep_flag_key] = dep_result
|
|
661
|
+
rescue InconclusiveMatchError => e
|
|
662
|
+
# If we can't evaluate a dependency, store nil and propagate the error
|
|
663
|
+
evaluation_cache[dep_flag_key] = nil
|
|
664
|
+
raise InconclusiveMatchError,
|
|
665
|
+
"Cannot evaluate flag dependency '#{dep_flag_key}': #{e.message}"
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Check the cached result
|
|
671
|
+
cached_result = evaluation_cache[dep_flag_key]
|
|
672
|
+
if cached_result.nil?
|
|
673
|
+
# Previously inconclusive - raise error again
|
|
674
|
+
raise InconclusiveMatchError,
|
|
675
|
+
"Flag dependency '#{dep_flag_key}' was previously inconclusive"
|
|
676
|
+
elsif !cached_result
|
|
677
|
+
# Definitive False result - dependency failed
|
|
678
|
+
return false
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Get the expected value of the immediate dependency and the actual value
|
|
683
|
+
expected_value = property[:value]
|
|
684
|
+
# The flag we want to evaluate is defined by :key which should ALSO be the last key in the dependency chain
|
|
685
|
+
actual_value = evaluation_cache[property[:key]]
|
|
686
|
+
|
|
687
|
+
self.class.matches_dependency_value(expected_value, actual_value)
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def self.matches_dependency_value(expected_value, actual_value)
|
|
691
|
+
# Check if the actual flag value matches the expected dependency value.
|
|
692
|
+
#
|
|
693
|
+
# - String variant case: check for exact match or boolean true
|
|
694
|
+
# - Boolean case: must match expected boolean value
|
|
695
|
+
#
|
|
696
|
+
# @param expected_value [Object] The expected value from the property
|
|
697
|
+
# @param actual_value [Object] The actual value returned by the flag evaluation
|
|
698
|
+
# @return [Boolean] True if the values match according to flag dependency rules
|
|
699
|
+
|
|
700
|
+
# String variant case - check for exact match or boolean true
|
|
701
|
+
if actual_value.is_a?(String) && !actual_value.empty?
|
|
702
|
+
if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
|
703
|
+
# Any variant matches boolean true
|
|
704
|
+
return expected_value
|
|
705
|
+
elsif expected_value.is_a?(String)
|
|
706
|
+
# variants are case-sensitive, hence our comparison is too
|
|
707
|
+
return actual_value == expected_value
|
|
708
|
+
else
|
|
709
|
+
return false
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Boolean case - must match expected boolean value
|
|
713
|
+
elsif actual_value.is_a?(TrueClass) || actual_value.is_a?(FalseClass)
|
|
714
|
+
return actual_value == expected_value if expected_value.is_a?(TrueClass) || expected_value.is_a?(FalseClass)
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Default case
|
|
718
|
+
false
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
private_class_method :extract_value, :find_cohort_property, :nested_property_group?,
|
|
722
|
+
:match_nested_property_group, :match_regular_property_group
|
|
723
|
+
|
|
724
|
+
def _compute_flag_locally(flag, distinct_id, groups = {}, person_properties = {}, group_properties = {})
|
|
725
|
+
raise RequiresServerEvaluation, 'Flag has experience continuity enabled' if flag[:ensure_experience_continuity]
|
|
726
|
+
|
|
727
|
+
return false unless flag[:active]
|
|
728
|
+
|
|
729
|
+
# Create evaluation cache for flag dependencies
|
|
730
|
+
evaluation_cache = {}
|
|
731
|
+
|
|
732
|
+
flag_filters = flag[:filters] || {}
|
|
733
|
+
|
|
734
|
+
aggregation_group_type_index = flag_filters[:aggregation_group_type_index]
|
|
735
|
+
if aggregation_group_type_index.nil?
|
|
736
|
+
return match_feature_flag_properties(flag, distinct_id, person_properties, evaluation_cache, @cohorts)
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
group_name = @group_type_mapping[aggregation_group_type_index.to_s.to_sym]
|
|
740
|
+
|
|
741
|
+
if group_name.nil?
|
|
742
|
+
logger.warn(
|
|
743
|
+
"[FEATURE FLAGS] Unknown group type index #{aggregation_group_type_index} for feature flag #{flag[:key]}"
|
|
744
|
+
)
|
|
745
|
+
# failover to `/flags/`
|
|
746
|
+
raise InconclusiveMatchError, 'Flag has unknown group type index'
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
group_name_symbol = group_name.to_sym
|
|
750
|
+
|
|
751
|
+
unless groups.key?(group_name_symbol)
|
|
752
|
+
# Group flags are never enabled if appropriate `groups` aren't passed in
|
|
753
|
+
# don't failover to `/flags/`, since response will be the same
|
|
754
|
+
logger.warn "[FEATURE FLAGS] Can't compute group feature flag: #{flag[:key]} without group names passed in"
|
|
755
|
+
return false
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
focused_group_properties = group_properties[group_name_symbol]
|
|
759
|
+
match_feature_flag_properties(flag, groups[group_name_symbol], focused_group_properties, evaluation_cache,
|
|
760
|
+
@cohorts)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def _compute_flag_payload_locally(key, match_value)
|
|
764
|
+
return nil if @feature_flags_by_key.nil?
|
|
765
|
+
|
|
766
|
+
response = nil
|
|
767
|
+
if [true, false].include? match_value
|
|
768
|
+
response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_s.to_sym)
|
|
769
|
+
elsif match_value.is_a? String
|
|
770
|
+
response = @feature_flags_by_key.dig(key, :filters, :payloads, match_value.to_sym)
|
|
771
|
+
end
|
|
772
|
+
response
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def match_feature_flag_properties(flag, distinct_id, properties, evaluation_cache, cohort_properties = {})
|
|
776
|
+
flag_filters = flag[:filters] || {}
|
|
777
|
+
|
|
778
|
+
flag_conditions = flag_filters[:groups] || []
|
|
779
|
+
is_inconclusive = false
|
|
780
|
+
result = nil
|
|
781
|
+
|
|
782
|
+
# NOTE: This NEEDS to be `each` because `each_key` breaks
|
|
783
|
+
flag_conditions.each do |condition|
|
|
784
|
+
if condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties)
|
|
785
|
+
variant_override = condition[:variant]
|
|
786
|
+
flag_multivariate = flag_filters[:multivariate] || {}
|
|
787
|
+
flag_variants = flag_multivariate[:variants] || []
|
|
788
|
+
variant = if flag_variants.map { |variant| variant[:key] }.include?(condition[:variant])
|
|
789
|
+
variant_override
|
|
790
|
+
else
|
|
791
|
+
get_matching_variant(flag, distinct_id)
|
|
792
|
+
end
|
|
793
|
+
result = variant || true
|
|
794
|
+
break
|
|
795
|
+
end
|
|
796
|
+
rescue RequiresServerEvaluation
|
|
797
|
+
# Static cohort or other missing server-side data - must fallback to API
|
|
798
|
+
raise
|
|
799
|
+
rescue InconclusiveMatchError
|
|
800
|
+
# Evaluation error (bad regex, invalid date, missing property, etc.)
|
|
801
|
+
# Track that we had an inconclusive match, but try other conditions
|
|
802
|
+
is_inconclusive = true
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
if !result.nil?
|
|
806
|
+
return result
|
|
807
|
+
elsif is_inconclusive
|
|
808
|
+
raise InconclusiveMatchError, "Can't determine if feature flag is enabled or not with given properties"
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
# We can only return False when all conditions are False
|
|
812
|
+
false
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def condition_match(flag, distinct_id, condition, properties, evaluation_cache, cohort_properties = {})
|
|
816
|
+
rollout_percentage = condition[:rollout_percentage]
|
|
817
|
+
|
|
818
|
+
unless (condition[:properties] || []).empty?
|
|
819
|
+
unless condition[:properties].all? do |prop|
|
|
820
|
+
if prop[:type] == 'flag'
|
|
821
|
+
evaluate_flag_dependency(prop, evaluation_cache, distinct_id, properties, cohort_properties)
|
|
822
|
+
else
|
|
823
|
+
FeatureFlagsPoller.match_property(prop, properties, cohort_properties)
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
return false
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
return true if rollout_percentage.nil?
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
return false if !rollout_percentage.nil? && (_hash(flag[:key], distinct_id) > (rollout_percentage.to_f / 100))
|
|
833
|
+
|
|
834
|
+
true
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
|
|
838
|
+
# Given the same distinct_id and key, it'll always return the same float. These floats are
|
|
839
|
+
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
|
|
840
|
+
# we can do _hash(key, distinct_id) < 0.2
|
|
841
|
+
def _hash(key, distinct_id, salt = '')
|
|
842
|
+
hash_key = Digest::SHA1.hexdigest "#{key}.#{distinct_id}#{salt}"
|
|
843
|
+
(Integer(hash_key[0..14], 16).to_f / 0xfffffffffffffff)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def get_matching_variant(flag, distinct_id)
|
|
847
|
+
hash_value = _hash(flag[:key], distinct_id, 'variant')
|
|
848
|
+
matching_variant = variant_lookup_table(flag).find do |variant|
|
|
849
|
+
hash_value >= variant[:value_min] and hash_value < variant[:value_max]
|
|
850
|
+
end
|
|
851
|
+
matching_variant.nil? ? nil : matching_variant[:key]
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def variant_lookup_table(flag)
|
|
855
|
+
lookup_table = []
|
|
856
|
+
value_min = 0
|
|
857
|
+
flag_filters = flag[:filters] || {}
|
|
858
|
+
variants = flag_filters[:multivariate] || {}
|
|
859
|
+
multivariates = variants[:variants] || []
|
|
860
|
+
multivariates.each do |variant|
|
|
861
|
+
value_max = value_min + (variant[:rollout_percentage].to_f / 100)
|
|
862
|
+
lookup_table << { value_min: value_min, value_max: value_max, key: variant[:key] }
|
|
863
|
+
value_min = value_max
|
|
864
|
+
end
|
|
865
|
+
lookup_table
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
def _load_feature_flags
|
|
869
|
+
begin
|
|
870
|
+
res = _request_feature_flag_definitions(etag: @flags_etag.value)
|
|
871
|
+
rescue StandardError => e
|
|
872
|
+
@on_error.call(-1, e.to_s)
|
|
873
|
+
return
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# Handle 304 Not Modified - flags haven't changed, skip processing
|
|
877
|
+
# Only update ETag if the 304 response includes one
|
|
878
|
+
if res[:not_modified]
|
|
879
|
+
@flags_etag.value = res[:etag] if res[:etag]
|
|
880
|
+
logger.debug '[FEATURE FLAGS] Flags not modified (304), using cached data'
|
|
881
|
+
return
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
# Handle quota limits with 402 status
|
|
885
|
+
if res.is_a?(Hash) && res[:status] == 402
|
|
886
|
+
logger.warn(
|
|
887
|
+
'[FEATURE FLAGS] Feature flags quota limit exceeded - unsetting all local flags. ' \
|
|
888
|
+
'Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts'
|
|
889
|
+
)
|
|
890
|
+
@feature_flags = Concurrent::Array.new
|
|
891
|
+
@feature_flags_by_key = {}
|
|
892
|
+
@group_type_mapping = Concurrent::Hash.new
|
|
893
|
+
@cohorts = Concurrent::Hash.new
|
|
894
|
+
@loaded_flags_successfully_once.make_false
|
|
895
|
+
@quota_limited.make_true
|
|
896
|
+
return
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
if res.key?(:flags)
|
|
900
|
+
# Only update ETag on successful responses with flag data
|
|
901
|
+
@flags_etag.value = res[:etag]
|
|
902
|
+
|
|
903
|
+
@feature_flags = res[:flags] || []
|
|
904
|
+
@feature_flags_by_key = {}
|
|
905
|
+
@feature_flags.each do |flag|
|
|
906
|
+
@feature_flags_by_key[flag[:key]] = flag unless flag[:key].nil?
|
|
907
|
+
end
|
|
908
|
+
@group_type_mapping = res[:group_type_mapping] || {}
|
|
909
|
+
@cohorts = res[:cohorts] || {}
|
|
910
|
+
|
|
911
|
+
logger.debug "Loaded #{@feature_flags.length} feature flags and #{@cohorts.length} cohorts"
|
|
912
|
+
@loaded_flags_successfully_once.make_true if @loaded_flags_successfully_once.false?
|
|
913
|
+
else
|
|
914
|
+
logger.debug "Failed to load feature flags: #{res}"
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def _request_feature_flag_definitions(etag: nil)
|
|
919
|
+
uri = URI("#{@host}/api/feature_flag/local_evaluation")
|
|
920
|
+
uri.query = URI.encode_www_form([['token', @project_api_key], %w[send_cohorts true]])
|
|
921
|
+
req = Net::HTTP::Get.new(uri)
|
|
922
|
+
req['Authorization'] = "Bearer #{@personal_api_key}"
|
|
923
|
+
req['If-None-Match'] = etag if etag
|
|
924
|
+
|
|
925
|
+
_request(uri, req, nil, include_etag: true)
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def _request_feature_flag_evaluation(data = {})
|
|
929
|
+
uri = URI("#{@host}/flags/?v=2")
|
|
930
|
+
req = Net::HTTP::Post.new(uri)
|
|
931
|
+
req['Content-Type'] = 'application/json'
|
|
932
|
+
data['token'] = @project_api_key
|
|
933
|
+
req.body = data.to_json
|
|
934
|
+
|
|
935
|
+
_request(uri, req, @feature_flag_request_timeout_seconds)
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
def _request_remote_config_payload(flag_key)
|
|
939
|
+
uri = URI("#{@host}/api/projects/@current/feature_flags/#{flag_key}/remote_config")
|
|
940
|
+
uri.query = URI.encode_www_form([['token', @project_api_key]])
|
|
941
|
+
req = Net::HTTP::Get.new(uri)
|
|
942
|
+
req['Content-Type'] = 'application/json'
|
|
943
|
+
req['Authorization'] = "Bearer #{@personal_api_key}"
|
|
944
|
+
|
|
945
|
+
_request(uri, req, @feature_flag_request_timeout_seconds)
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# rubocop:disable Lint/ShadowedException
|
|
949
|
+
def _request(uri, request_object, timeout = nil, include_etag: false)
|
|
950
|
+
request_object['User-Agent'] = "posthog-ruby#{PostHog::VERSION}"
|
|
951
|
+
request_timeout = timeout || 10
|
|
952
|
+
|
|
953
|
+
begin
|
|
954
|
+
Net::HTTP.start(
|
|
955
|
+
uri.hostname,
|
|
956
|
+
uri.port,
|
|
957
|
+
use_ssl: uri.scheme == 'https',
|
|
958
|
+
read_timeout: request_timeout
|
|
959
|
+
) do |http|
|
|
960
|
+
res = http.request(request_object)
|
|
961
|
+
status_code = res.code.to_i
|
|
962
|
+
etag = include_etag ? res['ETag'] : nil
|
|
963
|
+
|
|
964
|
+
# Handle 304 Not Modified - return special response indicating no change
|
|
965
|
+
if status_code == 304
|
|
966
|
+
logger.debug("#{request_object.method} #{_mask_tokens_in_url(uri.to_s)} returned 304 Not Modified")
|
|
967
|
+
return { not_modified: true, etag: etag, status: status_code }
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
# Parse response body to hash
|
|
971
|
+
begin
|
|
972
|
+
response = JSON.parse(res.body, { symbolize_names: true })
|
|
973
|
+
# Only add status (and etag if requested) if response is a hash
|
|
974
|
+
extra_fields = { status: status_code }
|
|
975
|
+
extra_fields[:etag] = etag if include_etag
|
|
976
|
+
response = response.merge(extra_fields) if response.is_a?(Hash)
|
|
977
|
+
return response
|
|
978
|
+
rescue JSON::ParserError
|
|
979
|
+
# Handle case when response isn't valid JSON
|
|
980
|
+
error_response = { error: 'Invalid JSON response', body: res.body, status: status_code }
|
|
981
|
+
error_response[:etag] = etag if include_etag
|
|
982
|
+
return error_response
|
|
983
|
+
end
|
|
984
|
+
end
|
|
985
|
+
rescue Timeout::Error,
|
|
986
|
+
Errno::EINVAL,
|
|
987
|
+
Errno::ECONNRESET,
|
|
988
|
+
EOFError,
|
|
989
|
+
Net::HTTPBadResponse,
|
|
990
|
+
Net::HTTPHeaderSyntaxError,
|
|
991
|
+
Net::ReadTimeout,
|
|
992
|
+
Net::WriteTimeout,
|
|
993
|
+
Net::ProtocolError
|
|
994
|
+
logger.debug("Unable to complete request to #{uri}")
|
|
995
|
+
raise
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
# rubocop:enable Lint/ShadowedException
|
|
999
|
+
|
|
1000
|
+
def _mask_tokens_in_url(url)
|
|
1001
|
+
url.gsub(/token=([^&]{10})[^&]*/, 'token=\1...')
|
|
1002
|
+
end
|
|
1003
|
+
end
|
|
1004
|
+
end
|