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.
@@ -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