featbit-server-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +201 -0
- data/README.md +252 -0
- data/lib/featbit/client.rb +282 -0
- data/lib/featbit/data_store.rb +188 -0
- data/lib/featbit/evaluation_detail.rb +12 -0
- data/lib/featbit/evaluator.rb +230 -0
- data/lib/featbit/event_processor.rb +225 -0
- data/lib/featbit/options.rb +107 -0
- data/lib/featbit/status.rb +103 -0
- data/lib/featbit/user.rb +66 -0
- data/lib/featbit/version.rb +5 -0
- data/lib/featbit/web_socket_data_synchronizer.rb +293 -0
- data/lib/featbit.rb +15 -0
- metadata +88 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
require "json"
|
|
5
|
+
require "set"
|
|
6
|
+
require "timeout"
|
|
7
|
+
|
|
8
|
+
module FeatBit
|
|
9
|
+
class Evaluator
|
|
10
|
+
REGEXP_TIMEOUT = 0.05
|
|
11
|
+
NATIVE_REGEXP_TIMEOUT_SUPPORTED = Regexp.respond_to?(:timeout)
|
|
12
|
+
|
|
13
|
+
REASONS = {
|
|
14
|
+
client_not_ready: "client not ready",
|
|
15
|
+
flag_not_found: "flag not found",
|
|
16
|
+
error: "error in evaluation",
|
|
17
|
+
user_not_specified: "user not specified",
|
|
18
|
+
wrong_type: "wrong type",
|
|
19
|
+
flag_off: "flag off",
|
|
20
|
+
target_match: "target match",
|
|
21
|
+
rule_match: "rule match",
|
|
22
|
+
fallthrough: "fall through all rules"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(flag_getter:, segment_getter:, logger: nil)
|
|
26
|
+
@flag_getter = flag_getter
|
|
27
|
+
@segment_getter = segment_getter
|
|
28
|
+
@logger = logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def evaluate(flag_key, user, default_value)
|
|
32
|
+
return error_detail(flag_key, default_value, :user_not_specified) unless user&.valid?
|
|
33
|
+
|
|
34
|
+
flag = @flag_getter.call(flag_key.to_s)
|
|
35
|
+
return error_detail(flag_key, default_value, :flag_not_found) unless flag
|
|
36
|
+
|
|
37
|
+
variation_id, reason, send_to_experiment = select_variation(flag, user, Set.new)
|
|
38
|
+
raise "no variation matched" if variation_id.to_s.empty?
|
|
39
|
+
|
|
40
|
+
raw_value = fetch(fetch(flag, "variationMap", {}), variation_id)
|
|
41
|
+
raise "variation value not found" if raw_value.nil?
|
|
42
|
+
|
|
43
|
+
value = cast(raw_value, fetch(flag, "variationType"))
|
|
44
|
+
EvaluationDetail.new(
|
|
45
|
+
value: value,
|
|
46
|
+
reason: REASONS.fetch(reason),
|
|
47
|
+
variation_id: variation_id,
|
|
48
|
+
flag_key: fetch(flag, "key", flag_key),
|
|
49
|
+
flag_name: fetch(flag, "name"),
|
|
50
|
+
send_to_experiment: send_to_experiment
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@logger&.error("FeatBit evaluation failed: #{e.message}")
|
|
54
|
+
error_detail(flag_key, default_value, :error, e.message)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def select_variation(flag, user, visited_segments)
|
|
60
|
+
return [fetch(flag, "disabledVariationId"), :flag_off, false] unless fetch(flag, "isEnabled", false)
|
|
61
|
+
|
|
62
|
+
Array(fetch(flag, "targetUsers", [])).each do |target|
|
|
63
|
+
if Array(fetch(target, "keyIds", [])).map(&:to_s).include?(user.key)
|
|
64
|
+
return [fetch(target, "variationId"), :target_match, fetch(flag, "exptIncludeAllTargets", false) == true]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
Array(fetch(flag, "rules", [])).each do |rule|
|
|
69
|
+
next unless Array(fetch(rule, "conditions", [])).all? do |condition|
|
|
70
|
+
condition_matches?(user, condition, visited_segments)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
variation_id, send_to_experiment = rollout_variation(flag, rule, user)
|
|
74
|
+
return [variation_id, :rule_match, send_to_experiment] if variation_id
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
variation_id, send_to_experiment = rollout_variation(flag, fetch(flag, "fallthrough", {}), user)
|
|
78
|
+
[variation_id, :fallthrough, send_to_experiment]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def rollout_variation(flag, rollout, user)
|
|
82
|
+
dispatch_key = fetch(rollout, "dispatchKey") || "keyId"
|
|
83
|
+
dispatch_value = user[dispatch_key].to_s
|
|
84
|
+
bucket_key = "#{fetch(flag, 'key')}#{dispatch_value}"
|
|
85
|
+
Array(fetch(rollout, "variations", [])).each do |variation|
|
|
86
|
+
range = Array(fetch(variation, "rollout", []))
|
|
87
|
+
next unless in_bucket?(bucket_key, range)
|
|
88
|
+
|
|
89
|
+
return [fetch(variation, "id"), send_to_experiment?(flag, rollout, variation, bucket_key, range)]
|
|
90
|
+
end
|
|
91
|
+
[nil, false]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def send_to_experiment?(flag, rollout, variation, bucket_key, range)
|
|
95
|
+
return true if fetch(flag, "exptIncludeAllTargets", false)
|
|
96
|
+
return false unless fetch(rollout, "includedInExpt", false)
|
|
97
|
+
|
|
98
|
+
span = range[1].to_f - range[0].to_f
|
|
99
|
+
experiment_rollout = fetch(variation, "exptRollout", 0).to_f
|
|
100
|
+
return false unless span.positive? && experiment_rollout.positive?
|
|
101
|
+
|
|
102
|
+
upper_bound = [experiment_rollout / span, 1.0].min
|
|
103
|
+
in_bucket?("expt#{bucket_key}", [0, upper_bound])
|
|
104
|
+
rescue StandardError
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def in_bucket?(key, range)
|
|
109
|
+
return false unless range.length == 2
|
|
110
|
+
return true if range[0].to_f.zero? && range[1].to_f >= 1.0
|
|
111
|
+
|
|
112
|
+
signed = Digest::MD5.digest(key.to_s).byteslice(0, 4).unpack1("l<")
|
|
113
|
+
percentage = (signed / -2_147_483_648.0).abs
|
|
114
|
+
percentage >= range[0].to_f && percentage < range[1].to_f
|
|
115
|
+
rescue StandardError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def condition_matches?(user, condition, visited_segments = Set.new)
|
|
120
|
+
property = fetch(condition, "property")
|
|
121
|
+
operation = fetch(condition, "op") || property
|
|
122
|
+
actual = user[property]
|
|
123
|
+
expected = fetch(condition, "value")
|
|
124
|
+
actual_number = numeric_value(actual)
|
|
125
|
+
expected_number = numeric_value(expected)
|
|
126
|
+
|
|
127
|
+
case operation
|
|
128
|
+
when "BiggerEqualThan" then actual_number && expected_number && actual_number >= expected_number
|
|
129
|
+
when "BiggerThan" then actual_number && expected_number && actual_number > expected_number
|
|
130
|
+
when "LessEqualThan" then actual_number && expected_number && actual_number <= expected_number
|
|
131
|
+
when "LessThan" then actual_number && expected_number && actual_number < expected_number
|
|
132
|
+
when "Equal" then !actual.nil? && !expected.nil? && actual.to_s == expected.to_s
|
|
133
|
+
when "NotEqual" then actual.nil? || expected.nil? || actual.to_s != expected.to_s
|
|
134
|
+
when "Contains" then !actual.nil? && !expected.nil? && actual.to_s.include?(expected.to_s)
|
|
135
|
+
when "NotContain" then actual.nil? || expected.nil? || !actual.to_s.include?(expected.to_s)
|
|
136
|
+
when "IsOneOf" then json_array(expected).map(&:to_s).include?(actual.to_s)
|
|
137
|
+
when "NotOneOf" then !json_array(expected).map(&:to_s).include?(actual.to_s)
|
|
138
|
+
when "StartsWith" then !actual.nil? && actual.to_s.start_with?(expected.to_s)
|
|
139
|
+
when "EndsWith" then !actual.nil? && actual.to_s.end_with?(expected.to_s)
|
|
140
|
+
when "IsTrue" then actual == true || actual.to_s.casecmp("true").zero?
|
|
141
|
+
when "IsFalse" then actual == false || actual.to_s.casecmp("false").zero?
|
|
142
|
+
when "MatchRegex" then !actual.nil? && regex_match(expected, actual) == true
|
|
143
|
+
when "NotMatchRegex" then actual.nil? || regex_match(expected, actual) == false
|
|
144
|
+
when "User is in segment" then segment_match?(user, expected, visited_segments)
|
|
145
|
+
when "User is not in segment" then !segment_match?(user, expected, visited_segments)
|
|
146
|
+
else false
|
|
147
|
+
end
|
|
148
|
+
rescue StandardError
|
|
149
|
+
false
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def regex_match(pattern, value)
|
|
153
|
+
if NATIVE_REGEXP_TIMEOUT_SUPPORTED
|
|
154
|
+
Regexp.new(pattern.to_s, timeout: REGEXP_TIMEOUT).match?(value.to_s)
|
|
155
|
+
else
|
|
156
|
+
Timeout.timeout(REGEXP_TIMEOUT) { Regexp.new(pattern.to_s).match?(value.to_s) }
|
|
157
|
+
end
|
|
158
|
+
rescue StandardError
|
|
159
|
+
nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def segment_match?(user, serialized_ids, visited_segments)
|
|
163
|
+
json_array(serialized_ids).any? do |segment_id|
|
|
164
|
+
normalized_id = segment_id.to_s
|
|
165
|
+
next false if visited_segments.include?(normalized_id)
|
|
166
|
+
|
|
167
|
+
segment = @segment_getter.call(normalized_id)
|
|
168
|
+
next false unless segment
|
|
169
|
+
next false if Array(fetch(segment, "excluded", [])).map(&:to_s).include?(user.key)
|
|
170
|
+
next true if Array(fetch(segment, "included", [])).map(&:to_s).include?(user.key)
|
|
171
|
+
|
|
172
|
+
nested_visited = visited_segments | [normalized_id]
|
|
173
|
+
Array(fetch(segment, "rules", [])).any? do |rule|
|
|
174
|
+
Array(fetch(rule, "conditions", [])).all? do |condition|
|
|
175
|
+
condition_matches?(user, condition, nested_visited)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cast(value, type)
|
|
182
|
+
case type.to_s.downcase
|
|
183
|
+
when "boolean" then cast_boolean(value)
|
|
184
|
+
when "number" then numeric_cast(value)
|
|
185
|
+
when "json" then value.is_a?(String) ? JSON.parse(value) : value
|
|
186
|
+
else value.to_s
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def cast_boolean(value)
|
|
191
|
+
return value if [true, false].include?(value)
|
|
192
|
+
return true if value.is_a?(String) && value.casecmp("true").zero?
|
|
193
|
+
return false if value.is_a?(String) && value.casecmp("false").zero?
|
|
194
|
+
|
|
195
|
+
raise "invalid boolean variation"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def numeric_cast(value)
|
|
199
|
+
number = Float(value)
|
|
200
|
+
number.finite? && number == number.to_i ? number.to_i : number
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def numeric_value(value)
|
|
204
|
+
Float(value).round(5)
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def json_array(value)
|
|
210
|
+
parsed = value.is_a?(String) ? JSON.parse(value) : value
|
|
211
|
+
parsed.is_a?(Array) ? parsed : []
|
|
212
|
+
rescue StandardError
|
|
213
|
+
[]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def error_detail(flag_key, default_value, kind, message = nil)
|
|
217
|
+
EvaluationDetail.new(
|
|
218
|
+
value: default_value,
|
|
219
|
+
reason: REASONS.fetch(kind),
|
|
220
|
+
flag_key: flag_key,
|
|
221
|
+
error_kind: kind,
|
|
222
|
+
error_message: message
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def fetch(hash, key, default = nil)
|
|
227
|
+
hash.is_a?(Hash) ? hash.fetch(key.to_s, hash.fetch(key.to_sym, default)) : default
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module FeatBit
|
|
9
|
+
class EventProcessor
|
|
10
|
+
class DeliveryRejected < StandardError; end
|
|
11
|
+
|
|
12
|
+
FLUSH = "__flush__"
|
|
13
|
+
STOP = "__stop__"
|
|
14
|
+
CONTROL_TIMEOUT = 5.0
|
|
15
|
+
|
|
16
|
+
def initialize(options, sender: nil)
|
|
17
|
+
@options = options
|
|
18
|
+
@sender = sender || method(:post_batch)
|
|
19
|
+
@queue = SizedQueue.new(options.events_capacity)
|
|
20
|
+
@control_queue = Queue.new
|
|
21
|
+
@dropped_events = 0
|
|
22
|
+
@drop_mutex = Mutex.new
|
|
23
|
+
@state_mutex = Mutex.new
|
|
24
|
+
@close_mutex = Mutex.new
|
|
25
|
+
@closed = false
|
|
26
|
+
@close_result = nil
|
|
27
|
+
@thread = Thread.new { run }
|
|
28
|
+
@thread.name = "featbit-event-processor" if @thread.respond_to?(:name=)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
options.logger&.error("FeatBit event processor failed to start: #{e.message}")
|
|
31
|
+
@closed = true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def enqueue(event)
|
|
35
|
+
return false if event.nil?
|
|
36
|
+
|
|
37
|
+
@state_mutex.synchronize do
|
|
38
|
+
return false if @closed
|
|
39
|
+
|
|
40
|
+
@queue.push(event, true)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
rescue ThreadError
|
|
44
|
+
@drop_mutex.synchronize { @dropped_events += 1 }
|
|
45
|
+
false
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
@options.logger&.warn("FeatBit event enqueue failed: #{e.message}")
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def flush
|
|
52
|
+
ack = Queue.new
|
|
53
|
+
@state_mutex.synchronize do
|
|
54
|
+
return false if @closed
|
|
55
|
+
|
|
56
|
+
@control_queue << { type: FLUSH, ack: ack }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
Timeout.timeout(CONTROL_TIMEOUT) { ack.pop } != false
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
@options.logger&.warn("FeatBit event flush failed: #{e.message}")
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
@close_mutex.synchronize do
|
|
67
|
+
return @close_result unless @close_result.nil?
|
|
68
|
+
|
|
69
|
+
ack = Queue.new
|
|
70
|
+
worker_alive = @thread&.alive?
|
|
71
|
+
@state_mutex.synchronize do
|
|
72
|
+
@closed = true
|
|
73
|
+
@control_queue << { type: STOP, ack: ack } if worker_alive
|
|
74
|
+
end
|
|
75
|
+
return @close_result = true unless worker_alive
|
|
76
|
+
|
|
77
|
+
delivered = Timeout.timeout(CONTROL_TIMEOUT) { ack.pop } != false
|
|
78
|
+
@thread&.join(CONTROL_TIMEOUT)
|
|
79
|
+
@close_result = delivered && !@thread&.alive?
|
|
80
|
+
end
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
@options.logger&.warn("FeatBit event processor close failed: #{e.message}")
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def dropped_events
|
|
87
|
+
@drop_mutex.synchronize { @dropped_events }
|
|
88
|
+
rescue StandardError
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def run
|
|
95
|
+
batch = []
|
|
96
|
+
deadline = monotonic_time + @options.events_flush_interval
|
|
97
|
+
loop do
|
|
98
|
+
event = pop_with_timeout([deadline - monotonic_time, 0].max)
|
|
99
|
+
if control?(event, STOP)
|
|
100
|
+
drain_events(batch)
|
|
101
|
+
delivered = send_batch(batch)
|
|
102
|
+
batch.clear
|
|
103
|
+
event[:ack].push(delivered)
|
|
104
|
+
break
|
|
105
|
+
elsif control?(event, FLUSH)
|
|
106
|
+
drain_events(batch)
|
|
107
|
+
delivered = send_batch(batch)
|
|
108
|
+
batch.clear
|
|
109
|
+
event[:ack].push(delivered)
|
|
110
|
+
deadline = monotonic_time + @options.events_flush_interval
|
|
111
|
+
elsif event
|
|
112
|
+
batch << event
|
|
113
|
+
if batch.length >= @options.events_batch_size
|
|
114
|
+
send_batch(batch)
|
|
115
|
+
batch.clear
|
|
116
|
+
deadline = monotonic_time + @options.events_flush_interval
|
|
117
|
+
end
|
|
118
|
+
elsif monotonic_time >= deadline
|
|
119
|
+
send_batch(batch)
|
|
120
|
+
batch.clear
|
|
121
|
+
deadline = monotonic_time + @options.events_flush_interval
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@options.logger&.error("FeatBit event processor stopped unexpectedly: #{e.message}")
|
|
126
|
+
ensure
|
|
127
|
+
mark_closed
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pop_with_timeout(timeout)
|
|
131
|
+
deadline = monotonic_time + timeout
|
|
132
|
+
loop do
|
|
133
|
+
return @control_queue.pop(true)
|
|
134
|
+
rescue ThreadError
|
|
135
|
+
begin
|
|
136
|
+
return @queue.pop(true)
|
|
137
|
+
rescue ThreadError
|
|
138
|
+
return nil if monotonic_time >= deadline
|
|
139
|
+
|
|
140
|
+
sleep([deadline - monotonic_time, 0.01].min)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def drain_events(batch)
|
|
146
|
+
loop do
|
|
147
|
+
batch << @queue.pop(true)
|
|
148
|
+
rescue ThreadError
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
batch
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def control?(event, type)
|
|
155
|
+
event.is_a?(Hash) && event[:type] == type
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def send_batch(batch)
|
|
159
|
+
return true if batch.empty?
|
|
160
|
+
|
|
161
|
+
delivered = true
|
|
162
|
+
batch.each_slice(@options.events_batch_size) do |slice|
|
|
163
|
+
delivered = false unless send_slice(slice)
|
|
164
|
+
end
|
|
165
|
+
delivered
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def send_slice(batch)
|
|
169
|
+
attempts = 0
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
attempts += 1
|
|
173
|
+
delivered = @sender.call(batch.dup)
|
|
174
|
+
raise "sender rejected batch" if delivered == false
|
|
175
|
+
|
|
176
|
+
true
|
|
177
|
+
rescue DeliveryRejected
|
|
178
|
+
raise
|
|
179
|
+
rescue StandardError
|
|
180
|
+
retry if attempts < 3
|
|
181
|
+
|
|
182
|
+
raise
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
@options.logger&.warn("FeatBit event delivery failed: #{e.message}")
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def post_batch(batch)
|
|
190
|
+
uri = URI.parse(@options.events_uri)
|
|
191
|
+
request = Net::HTTP::Post.new(uri)
|
|
192
|
+
request["Authorization"] = @options.env_secret
|
|
193
|
+
request["User-Agent"] = "featbit-ruby-server-sdk/#{FeatBit::VERSION}"
|
|
194
|
+
request["Content-Type"] = "application/json"
|
|
195
|
+
request.body = JSON.generate(batch)
|
|
196
|
+
response = Net::HTTP.start(
|
|
197
|
+
uri.host, uri.port,
|
|
198
|
+
use_ssl: uri.scheme == "https",
|
|
199
|
+
open_timeout: @options.connect_timeout,
|
|
200
|
+
read_timeout: @options.read_timeout
|
|
201
|
+
) { |http| http.request(request) }
|
|
202
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
203
|
+
message = response.body.to_s.gsub(/\s+/, " ").strip[0, 200]
|
|
204
|
+
raise DeliveryRejected, ["HTTP #{response.code}", message].reject(&:empty?).join(": ")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def mark_closed
|
|
211
|
+
@state_mutex.synchronize { @closed = true }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def monotonic_time
|
|
215
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
class NullEventProcessor
|
|
220
|
+
def enqueue(_event) = false
|
|
221
|
+
def flush = true
|
|
222
|
+
def close = true
|
|
223
|
+
def dropped_events = 0
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module FeatBit
|
|
7
|
+
class Options
|
|
8
|
+
DEFAULT_STREAMING_URL = "wss://app-eval.featbit.co"
|
|
9
|
+
DEFAULT_EVENT_URL = "https://app-eval.featbit.co"
|
|
10
|
+
|
|
11
|
+
attr_reader :env_secret, :streaming_url, :event_url, :start_wait,
|
|
12
|
+
:offline, :bootstrap, :disable_events, :logger,
|
|
13
|
+
:events_capacity, :events_flush_interval, :events_batch_size,
|
|
14
|
+
:connect_timeout, :read_timeout, :reconnect_delay,
|
|
15
|
+
:data_store, :synchronizer_factory, :event_processor_factory
|
|
16
|
+
|
|
17
|
+
def initialize(env_secret: "", streaming_url: DEFAULT_STREAMING_URL,
|
|
18
|
+
event_url: DEFAULT_EVENT_URL, start_wait: 5.0, offline: false,
|
|
19
|
+
bootstrap: nil, disable_events: false, logger: nil,
|
|
20
|
+
events_capacity: 10_000, events_flush_interval: 1.0,
|
|
21
|
+
events_batch_size: 50, connect_timeout: 5.0,
|
|
22
|
+
read_timeout: 10.0, reconnect_delay: 1.0,
|
|
23
|
+
data_store: nil, synchronizer_factory: nil,
|
|
24
|
+
event_processor_factory: nil)
|
|
25
|
+
@env_secret = env_secret.to_s
|
|
26
|
+
@streaming_url = normalize_url(streaming_url)
|
|
27
|
+
@event_url = normalize_url(event_url)
|
|
28
|
+
@start_wait = positive_number(start_wait, 5.0)
|
|
29
|
+
@offline = offline == true
|
|
30
|
+
@bootstrap = bootstrap
|
|
31
|
+
@disable_events = disable_events == true
|
|
32
|
+
@logger = logger || Logger.new($stderr, level: Logger::WARN)
|
|
33
|
+
@events_capacity = positive_integer(events_capacity, 10_000)
|
|
34
|
+
@events_flush_interval = positive_number(events_flush_interval, 1.0)
|
|
35
|
+
@events_batch_size = positive_integer(events_batch_size, 50)
|
|
36
|
+
@connect_timeout = positive_number(connect_timeout, 5.0)
|
|
37
|
+
@read_timeout = positive_number(read_timeout, 10.0)
|
|
38
|
+
@reconnect_delay = positive_number(reconnect_delay, 1.0)
|
|
39
|
+
@data_store = data_store
|
|
40
|
+
@synchronizer_factory = synchronizer_factory
|
|
41
|
+
@event_processor_factory = event_processor_factory
|
|
42
|
+
freeze
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
warn("FeatBit options error: #{e.message}")
|
|
45
|
+
@env_secret = ""
|
|
46
|
+
@streaming_url = DEFAULT_STREAMING_URL
|
|
47
|
+
@event_url = DEFAULT_EVENT_URL
|
|
48
|
+
@start_wait = 5.0
|
|
49
|
+
@offline = true
|
|
50
|
+
@bootstrap = nil
|
|
51
|
+
@disable_events = true
|
|
52
|
+
@logger = Logger.new($stderr, level: Logger::WARN)
|
|
53
|
+
@events_capacity = 10_000
|
|
54
|
+
@events_flush_interval = 1.0
|
|
55
|
+
@events_batch_size = 50
|
|
56
|
+
@connect_timeout = 5.0
|
|
57
|
+
@read_timeout = 10.0
|
|
58
|
+
@reconnect_delay = 1.0
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def streaming_uri
|
|
63
|
+
append_path(streaming_url, "/streaming")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def events_uri
|
|
67
|
+
append_path(event_url, "/api/public/insight/track")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def valid?
|
|
71
|
+
return true if offline
|
|
72
|
+
|
|
73
|
+
!env_secret.empty? && valid_uri?(streaming_uri, %w[ws wss]) && valid_uri?(events_uri, %w[http https])
|
|
74
|
+
rescue StandardError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def normalize_url(value)
|
|
81
|
+
value.to_s.sub(%r{/+\z}, "")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def append_path(base, path)
|
|
85
|
+
base.end_with?(path) ? base : "#{base}#{path}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid_uri?(value, schemes)
|
|
89
|
+
uri = URI.parse(value)
|
|
90
|
+
schemes.include?(uri.scheme) && !uri.host.to_s.empty?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def positive_number(value, fallback)
|
|
94
|
+
number = Float(value)
|
|
95
|
+
number.positive? ? number : fallback
|
|
96
|
+
rescue StandardError
|
|
97
|
+
fallback
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def positive_integer(value, fallback)
|
|
101
|
+
number = Integer(value)
|
|
102
|
+
number.positive? ? number : fallback
|
|
103
|
+
rescue StandardError
|
|
104
|
+
fallback
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatBit
|
|
4
|
+
module Status
|
|
5
|
+
STARTING = :starting
|
|
6
|
+
READY = :ready
|
|
7
|
+
INTERRUPTED = :interrupted
|
|
8
|
+
OFFLINE = :offline
|
|
9
|
+
FAILED = :failed
|
|
10
|
+
CLOSED = :closed
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class StatusProvider
|
|
14
|
+
def initialize(initial = Status::STARTING, logger: nil)
|
|
15
|
+
@status = initial
|
|
16
|
+
@message = nil
|
|
17
|
+
@listeners = {}
|
|
18
|
+
@next_listener_id = 0
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
@condition = ConditionVariable.new
|
|
21
|
+
@logger = logger
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def status
|
|
25
|
+
@mutex.synchronize { @status }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def message
|
|
29
|
+
@mutex.synchronize { @message }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ready?
|
|
33
|
+
status == Status::READY
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def wait_until_ready(timeout = 5.0)
|
|
37
|
+
deadline = monotonic_time + timeout.to_f
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
until @status == Status::READY
|
|
40
|
+
return false if [Status::FAILED, Status::CLOSED].include?(@status)
|
|
41
|
+
|
|
42
|
+
remaining = deadline - monotonic_time
|
|
43
|
+
return false unless remaining.positive?
|
|
44
|
+
|
|
45
|
+
@condition.wait(@mutex, remaining)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def add_listener(callable = nil, &block)
|
|
54
|
+
listener = callable || block
|
|
55
|
+
return nil unless listener.respond_to?(:call)
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@next_listener_id += 1
|
|
59
|
+
@listeners[@next_listener_id] = listener
|
|
60
|
+
@next_listener_id
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def remove_listener(id)
|
|
67
|
+
@mutex.synchronize { !@listeners.delete(id).nil? }
|
|
68
|
+
rescue StandardError
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def update(new_status, message: nil)
|
|
73
|
+
listeners = @mutex.synchronize do
|
|
74
|
+
changed = @status != new_status || @message != message
|
|
75
|
+
@status = new_status
|
|
76
|
+
@message = message
|
|
77
|
+
@condition.broadcast
|
|
78
|
+
changed ? @listeners.values.dup : []
|
|
79
|
+
end
|
|
80
|
+
listeners.each do |listener|
|
|
81
|
+
listener.call(new_status, message)
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
safe_log(:warn, "FeatBit status listener failed: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
true
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
safe_log(:warn, "FeatBit status update failed: #{e.message}")
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def monotonic_time
|
|
94
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def safe_log(level, message)
|
|
98
|
+
@logger&.public_send(level, message)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|