chronos-ruby 0.2.0.pre.1 → 0.4.0.pre.1
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/CHANGELOG.md +48 -0
- data/README.md +60 -19
- data/contracts/rack-context-v1.schema.json +43 -0
- data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
- data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
- data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
- data/docs/architecture.md +15 -6
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +22 -0
- data/docs/data-collected.md +8 -2
- data/docs/examples/plain-ruby.md +6 -0
- data/docs/modules/async-queue.md +6 -2
- data/docs/modules/notice-pipeline.md +2 -1
- data/docs/modules/rack-context.md +59 -0
- data/docs/modules/remote-configuration.md +54 -0
- data/docs/modules/retry-backlog.md +60 -0
- data/docs/modules/transport.md +5 -3
- data/docs/performance.md +39 -2
- data/docs/privacy-lgpd.md +10 -2
- data/docs/troubleshooting.md +10 -2
- data/lib/chronos/adapters/net_http_transport.rb +21 -3
- data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
- data/lib/chronos/agent.rb +68 -11
- data/lib/chronos/application/capture_exception.rb +20 -14
- data/lib/chronos/application/circuit_breaker.rb +70 -0
- data/lib/chronos/application/delivery_pipeline.rb +248 -0
- data/lib/chronos/application/remote_configuration.rb +173 -0
- data/lib/chronos/application/retry_policy.rb +57 -0
- data/lib/chronos/configuration.rb +128 -20
- data/lib/chronos/core/breadcrumb.rb +126 -0
- data/lib/chronos/core/payload_serializer.rb +4 -2
- data/lib/chronos/integrations/rack/middleware.rb +152 -0
- data/lib/chronos/integrations/rack.rb +12 -0
- data/lib/chronos/integrations.rb +10 -0
- data/lib/chronos/internal/bounded_queue.rb +1 -1
- data/lib/chronos/internal/memory_backlog.rb +65 -0
- data/lib/chronos/internal/worker_pool.rb +5 -6
- data/lib/chronos/ports/context_store.rb +22 -0
- data/lib/chronos/ports/transport.rb +4 -3
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +26 -1
- metadata +18 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Coordinates bounded queueing, retry, backlog, circuit state, and delivery.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Move serialized events through explicit delivery states.
|
|
6
|
+
# @motivation Keep resilience policy out of transports, workers, and the public facade.
|
|
7
|
+
# @limits It stores only sanitized SerializedEvent objects and never writes to disk.
|
|
8
|
+
# @collaborators RetryPolicy, CircuitBreaker, RemoteConfiguration, WorkerPool, and Transport.
|
|
9
|
+
# @thread_safety Mutable state, backlog, queue, and circuit transitions are synchronized.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of frameworks.
|
|
11
|
+
# @example
|
|
12
|
+
# pipeline.enqueue(serialized_event)
|
|
13
|
+
# pipeline.flush(2.0)
|
|
14
|
+
# @errors Transport and policy failures are contained and diagnosed.
|
|
15
|
+
# @performance Queue, retry count, backlog, and state counters are strictly bounded.
|
|
16
|
+
class DeliveryPipeline
|
|
17
|
+
STATES = [:accepted, :queued, :serialized, :sent, :retried, :dropped, :rejected].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :remote_configuration
|
|
20
|
+
|
|
21
|
+
def initialize(config, transport, logger = nil, options = {})
|
|
22
|
+
@config = config
|
|
23
|
+
@transport = transport
|
|
24
|
+
@logger = logger || Internal::SafeLogger.new(config.logger)
|
|
25
|
+
initialize_resilience(options)
|
|
26
|
+
initialize_storage(options)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def enqueue(event)
|
|
30
|
+
record_pre_delivery(event)
|
|
31
|
+
if @worker_pool.enqueue(event)
|
|
32
|
+
transition(:queued)
|
|
33
|
+
true
|
|
34
|
+
else
|
|
35
|
+
transition(:dropped)
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => error
|
|
39
|
+
diagnose(error)
|
|
40
|
+
transition(:dropped)
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def deliver_sync(event)
|
|
45
|
+
record_pre_delivery(event)
|
|
46
|
+
result = deliver_with_backlog(event)
|
|
47
|
+
result && result.success?
|
|
48
|
+
rescue StandardError => error
|
|
49
|
+
diagnose(error)
|
|
50
|
+
store_in_backlog(event)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# WorkerPool delivery entry point. Events were counted when accepted into the queue.
|
|
55
|
+
def send_event(event)
|
|
56
|
+
deliver_with_backlog(event)
|
|
57
|
+
rescue StandardError => error
|
|
58
|
+
diagnose(error)
|
|
59
|
+
store_in_backlog(event)
|
|
60
|
+
Ports::TransportResult.new(:network_error, :error => error.class.name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def capture_allowed?(event_type, fingerprint = nil)
|
|
64
|
+
@remote_configuration.capture?(event_type, fingerprint)
|
|
65
|
+
rescue StandardError => error
|
|
66
|
+
diagnose(error)
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def max_payload_size
|
|
71
|
+
@remote_configuration.max_payload_size
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def flush(timeout)
|
|
75
|
+
@worker_pool.flush(timeout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def close(timeout)
|
|
79
|
+
return true if closed?
|
|
80
|
+
|
|
81
|
+
@state_mutex.synchronize { @closed = true }
|
|
82
|
+
flushed = @worker_pool.close(timeout)
|
|
83
|
+
@transport.close
|
|
84
|
+
flushed
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
diagnose(error)
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def diagnostics
|
|
91
|
+
{
|
|
92
|
+
:states => state_counts,
|
|
93
|
+
:queue => @queue.stats,
|
|
94
|
+
:backlog => @backlog.stats,
|
|
95
|
+
:circuit => @circuit_breaker.state,
|
|
96
|
+
:remote_configuration => @remote_configuration.to_h
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def initialize_resilience(options)
|
|
103
|
+
@clock = options[:clock] || method(:monotonic_time)
|
|
104
|
+
@sleeper = options[:sleeper] || proc { |delay| sleep(delay) }
|
|
105
|
+
@remote_configuration = options[:remote_configuration] || RemoteConfiguration.new(@config)
|
|
106
|
+
@retry_policy = options[:retry_policy] || build_retry_policy(options[:random])
|
|
107
|
+
@circuit_breaker = options[:circuit_breaker] || CircuitBreaker.new(
|
|
108
|
+
@config.circuit_failure_threshold,
|
|
109
|
+
@config.circuit_reset_timeout,
|
|
110
|
+
@clock
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def initialize_storage(options)
|
|
115
|
+
@backlog = options[:backlog] || Internal::MemoryBacklog.new(@config.backlog_size)
|
|
116
|
+
@queue = options[:queue] || Internal::BoundedQueue.new(@config.queue_size)
|
|
117
|
+
@state_mutex = Mutex.new
|
|
118
|
+
@states = STATES.each_with_object({}) { |state, counts| counts[state] = 0 }
|
|
119
|
+
@send_mutex = Mutex.new
|
|
120
|
+
@last_send_at = nil
|
|
121
|
+
@closed = false
|
|
122
|
+
@worker_pool = options[:worker_pool] || Internal::WorkerPool.new(@queue, self, @config.workers, @logger)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_retry_policy(random)
|
|
126
|
+
RetryPolicy.new(
|
|
127
|
+
:max_retries => @config.max_retries,
|
|
128
|
+
:base_interval => @config.retry_base_interval,
|
|
129
|
+
:max_interval => @config.retry_max_interval,
|
|
130
|
+
:jitter => @config.retry_jitter,
|
|
131
|
+
:random => random
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record_pre_delivery(event)
|
|
136
|
+
raise ArgumentError, "delivery requires a SerializedEvent" unless event.is_a?(Core::SerializedEvent)
|
|
137
|
+
raise Error, "delivery pipeline is closed" if closed?
|
|
138
|
+
|
|
139
|
+
transition(:accepted)
|
|
140
|
+
transition(:serialized)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def deliver_with_backlog(event)
|
|
144
|
+
drain_one_backlog unless @backlog.empty?
|
|
145
|
+
attempt_delivery(event)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def drain_one_backlog
|
|
149
|
+
event = @backlog.shift
|
|
150
|
+
attempt_delivery(event) if event
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def attempt_delivery(event)
|
|
154
|
+
unless @circuit_breaker.allow_request?
|
|
155
|
+
transition(:retried)
|
|
156
|
+
store_in_backlog(event)
|
|
157
|
+
return Ports::TransportResult.new(:circuit_open)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
retries = 0
|
|
161
|
+
loop do
|
|
162
|
+
wait_for_send_interval
|
|
163
|
+
result = @transport.send_event(event)
|
|
164
|
+
return finish_success(result) if result.success?
|
|
165
|
+
return finish_permanent_failure(result) unless result.retryable?
|
|
166
|
+
|
|
167
|
+
@circuit_breaker.record_failure
|
|
168
|
+
unless @retry_policy.retry?(result, retries) && @circuit_breaker.allow_request?
|
|
169
|
+
store_in_backlog(event)
|
|
170
|
+
return result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
retries += 1
|
|
174
|
+
transition(:retried)
|
|
175
|
+
@sleeper.call(@retry_policy.delay(retries, result))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def finish_success(result)
|
|
180
|
+
@circuit_breaker.record_success
|
|
181
|
+
apply_remote_configuration(result)
|
|
182
|
+
transition(:sent)
|
|
183
|
+
result
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def finish_permanent_failure(result)
|
|
187
|
+
@circuit_breaker.record_success
|
|
188
|
+
transition(result.status == :client_error ? :rejected : :dropped)
|
|
189
|
+
result
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def store_in_backlog(event)
|
|
193
|
+
accepted = @backlog.push(event)
|
|
194
|
+
transition(:dropped) unless accepted
|
|
195
|
+
accepted
|
|
196
|
+
rescue StandardError => error
|
|
197
|
+
diagnose(error)
|
|
198
|
+
transition(:dropped)
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def apply_remote_configuration(result)
|
|
203
|
+
values = result.remote_configuration
|
|
204
|
+
return unless values && @config.remote_configuration
|
|
205
|
+
|
|
206
|
+
@remote_configuration.apply(values)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def wait_for_send_interval
|
|
210
|
+
interval = @remote_configuration.send_interval
|
|
211
|
+
return if interval <= 0.0
|
|
212
|
+
|
|
213
|
+
@send_mutex.synchronize do
|
|
214
|
+
now = @clock.call
|
|
215
|
+
if @last_send_at
|
|
216
|
+
remaining = interval - (now - @last_send_at)
|
|
217
|
+
@sleeper.call(remaining) if remaining > 0.0
|
|
218
|
+
end
|
|
219
|
+
@last_send_at = @clock.call
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def transition(state)
|
|
224
|
+
@state_mutex.synchronize { @states[state] += 1 }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def state_counts
|
|
228
|
+
@state_mutex.synchronize { @states.dup }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def closed?
|
|
232
|
+
@state_mutex.synchronize { @closed }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def diagnose(error)
|
|
236
|
+
@logger.warn("Chronos delivery failed: #{error.class}")
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def monotonic_time
|
|
240
|
+
if Process.respond_to?(:clock_gettime) && defined?(Process::CLOCK_MONOTONIC)
|
|
241
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
242
|
+
else
|
|
243
|
+
Time.now.to_f
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Runtime policy restricted to server-authorized scalar options.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Validate and apply bounded remote delivery controls.
|
|
6
|
+
# @motivation Let operators reduce telemetry safely without redeploying an application.
|
|
7
|
+
# @limits It cannot change host, credentials, TLS, code, or regular expressions.
|
|
8
|
+
# @collaborators Configuration snapshot, CaptureException, and DeliveryPipeline.
|
|
9
|
+
# @thread_safety A mutex protects immutable state replacements.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# remote.apply("sampling_rate" => 0.5, "kill_switch" => false)
|
|
13
|
+
# @errors Invalid documents are ignored and return false.
|
|
14
|
+
# @performance Validation is bounded by configured document and list limits.
|
|
15
|
+
class RemoteConfiguration
|
|
16
|
+
SUPPORTED_EVENT_TYPES = ["exception"].freeze
|
|
17
|
+
MAX_IGNORED_FINGERPRINTS = 100
|
|
18
|
+
MAX_FINGERPRINT_BYTES = 256
|
|
19
|
+
MIN_PAYLOAD_SIZE = 256
|
|
20
|
+
|
|
21
|
+
attr_reader :ignored_fingerprints
|
|
22
|
+
|
|
23
|
+
def initialize(config, options = {})
|
|
24
|
+
@config = config
|
|
25
|
+
@random = options[:random] || proc { rand }
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@local_event_types = allowed_event_types(config.enabled_event_types)
|
|
28
|
+
@values = {
|
|
29
|
+
"sampling_rate" => config.sampling_rate.to_f,
|
|
30
|
+
"enabled_event_types" => @local_event_types,
|
|
31
|
+
"max_payload_size" => config.max_payload_size,
|
|
32
|
+
"ignored_fingerprints" => [],
|
|
33
|
+
"send_interval" => 0.0,
|
|
34
|
+
"kill_switch" => false
|
|
35
|
+
}
|
|
36
|
+
@ignored_fingerprints = [].freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def apply(document)
|
|
40
|
+
normalized = normalize(document)
|
|
41
|
+
return false unless normalized
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@values = @values.merge(normalized)
|
|
45
|
+
@ignored_fingerprints = @values["ignored_fingerprints"].dup.freeze
|
|
46
|
+
end
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def capture?(event_type, fingerprint = nil)
|
|
53
|
+
values = snapshot
|
|
54
|
+
return false if values["kill_switch"]
|
|
55
|
+
return false unless values["enabled_event_types"].include?(event_type.to_s)
|
|
56
|
+
return false if fingerprint && values["ignored_fingerprints"].include?(fingerprint.to_s)
|
|
57
|
+
|
|
58
|
+
rate = values["sampling_rate"]
|
|
59
|
+
rate >= 1.0 || (rate > 0.0 && @random.call.to_f < rate)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def enabled_event?(event_type)
|
|
63
|
+
snapshot["enabled_event_types"].include?(event_type.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ignored_fingerprint?(fingerprint)
|
|
67
|
+
snapshot["ignored_fingerprints"].include?(fingerprint.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sampling_rate
|
|
71
|
+
snapshot["sampling_rate"]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def max_payload_size
|
|
75
|
+
snapshot["max_payload_size"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def send_interval
|
|
79
|
+
snapshot["send_interval"]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def kill_switch?
|
|
83
|
+
snapshot["kill_switch"]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_h
|
|
87
|
+
snapshot
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def normalize(document)
|
|
93
|
+
return nil unless document.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
normalized = {}
|
|
96
|
+
document.each do |key, value|
|
|
97
|
+
name = key.to_s
|
|
98
|
+
next unless @values.key?(name)
|
|
99
|
+
|
|
100
|
+
normalized_value = normalize_value(name, value)
|
|
101
|
+
return nil if normalized_value.nil? && value != false
|
|
102
|
+
normalized[name] = normalized_value
|
|
103
|
+
end
|
|
104
|
+
normalized.empty? ? nil : normalized
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_value(name, value)
|
|
108
|
+
case name
|
|
109
|
+
when "sampling_rate"
|
|
110
|
+
bounded_rate(value)
|
|
111
|
+
when "enabled_event_types"
|
|
112
|
+
normalize_event_types(value)
|
|
113
|
+
when "max_payload_size"
|
|
114
|
+
normalize_payload_size(value)
|
|
115
|
+
when "ignored_fingerprints"
|
|
116
|
+
normalize_fingerprints(value)
|
|
117
|
+
when "send_interval"
|
|
118
|
+
normalize_send_interval(value)
|
|
119
|
+
when "kill_switch"
|
|
120
|
+
value if [true, false].include?(value)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def bounded_rate(value)
|
|
125
|
+
return nil unless value.is_a?(Numeric) && value >= 0.0 && value <= @config.sampling_rate
|
|
126
|
+
|
|
127
|
+
value.to_f
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def normalize_event_types(value)
|
|
131
|
+
return nil unless value.is_a?(Array) && value.all? { |item| item.is_a?(String) }
|
|
132
|
+
|
|
133
|
+
allowed_event_types(value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def allowed_event_types(values)
|
|
137
|
+
supported = values.map(&:to_s).select { |value| SUPPORTED_EVENT_TYPES.include?(value) }
|
|
138
|
+
supported.select { |value| local_event_types.include?(value) }.uniq.freeze
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def local_event_types
|
|
142
|
+
@local_event_types || SUPPORTED_EVENT_TYPES
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_payload_size(value)
|
|
146
|
+
return nil unless value.is_a?(Integer) && value >= MIN_PAYLOAD_SIZE && value <= @config.max_payload_size
|
|
147
|
+
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def normalize_fingerprints(value)
|
|
152
|
+
return nil unless value.is_a?(Array) && value.length <= MAX_IGNORED_FINGERPRINTS
|
|
153
|
+
return nil unless value.all? { |item| item.is_a?(String) && item.bytesize <= MAX_FINGERPRINT_BYTES }
|
|
154
|
+
|
|
155
|
+
value.map(&:dup).freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def normalize_send_interval(value)
|
|
159
|
+
return nil unless value.is_a?(Numeric) && value >= 0.0 && value <= @config.max_remote_send_interval
|
|
160
|
+
|
|
161
|
+
value.to_f
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def snapshot
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
@values.each_with_object({}) do |(key, value), copy|
|
|
167
|
+
copy[key] = value.is_a?(Array) ? value.dup : value
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Application
|
|
5
|
+
# Bounded retry timing policy with exponential backoff and jitter.
|
|
6
|
+
#
|
|
7
|
+
# @responsibility Decide whether and when a retry may occur.
|
|
8
|
+
# @motivation Centralize retry limits independently from HTTP and worker code.
|
|
9
|
+
# @limits It does not sleep, send events, or retain payloads.
|
|
10
|
+
# @collaborators TransportResult and DeliveryPipeline.
|
|
11
|
+
# @thread_safety Immutable after construction when the random callable is thread-safe.
|
|
12
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
13
|
+
# @example
|
|
14
|
+
# policy.delay(2, result)
|
|
15
|
+
# @errors Invalid configuration is rejected by Configuration.
|
|
16
|
+
# @performance Constant time per decision.
|
|
17
|
+
class RetryPolicy
|
|
18
|
+
def initialize(options)
|
|
19
|
+
@max_retries = options[:max_retries]
|
|
20
|
+
@base_interval = options[:base_interval]
|
|
21
|
+
@max_interval = options[:max_interval]
|
|
22
|
+
@jitter = options[:jitter]
|
|
23
|
+
@random = options[:random] || proc { rand }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def retry?(result, retries)
|
|
27
|
+
result.retryable? && retries < @max_retries
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delay(retry_number, result = nil)
|
|
31
|
+
retry_after = retry_after_seconds(result)
|
|
32
|
+
return [retry_after, @max_interval].min if retry_after
|
|
33
|
+
|
|
34
|
+
base = @base_interval * (2**(retry_number - 1))
|
|
35
|
+
jittered = base * (1.0 + (@random.call.to_f * @jitter))
|
|
36
|
+
[jittered, @max_interval].min
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def retry_after_seconds(result)
|
|
42
|
+
return nil unless result && result.retry_after
|
|
43
|
+
|
|
44
|
+
value = result.retry_after.to_s
|
|
45
|
+
seconds = Float(value)
|
|
46
|
+
seconds >= 0 ? seconds : nil
|
|
47
|
+
rescue ArgumentError, TypeError
|
|
48
|
+
begin
|
|
49
|
+
seconds = Time.httpdate(value) - Time.now
|
|
50
|
+
seconds > 0 ? seconds : nil
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -24,6 +24,7 @@ module Chronos
|
|
|
24
24
|
token access_token refresh_token private_key client_secret cookie set-cookie
|
|
25
25
|
session credit_card card_number cvv cpf cnpj
|
|
26
26
|
).freeze
|
|
27
|
+
CONTEXT_STORE_METHODS = [:get, :set, :clear, :with_context].freeze
|
|
27
28
|
|
|
28
29
|
ATTRIBUTES = [
|
|
29
30
|
:project_id, :project_key, :host, :environment, :app_version,
|
|
@@ -31,12 +32,54 @@ module Chronos
|
|
|
31
32
|
:queue_size, :workers, :enabled, :error_notifications,
|
|
32
33
|
:ignored_environments, :proxy, :ssl_verify, :user_agent,
|
|
33
34
|
:max_payload_size, :gzip, :blocklist_keys, :allowlist_keys,
|
|
34
|
-
:filters, :hash_keys, :anonymize_ip
|
|
35
|
+
:filters, :hash_keys, :anonymize_ip, :max_retries,
|
|
36
|
+
:retry_base_interval, :retry_max_interval, :retry_jitter,
|
|
37
|
+
:backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
|
|
38
|
+
:remote_configuration, :remote_config_max_bytes, :sampling_rate,
|
|
39
|
+
:enabled_event_types, :max_remote_send_interval, :context_store,
|
|
40
|
+
:breadcrumb_capacity, :breadcrumb_max_bytes
|
|
35
41
|
].freeze
|
|
36
42
|
|
|
37
43
|
attr_accessor(*ATTRIBUTES)
|
|
38
44
|
|
|
39
45
|
def initialize
|
|
46
|
+
initialize_core_defaults
|
|
47
|
+
initialize_privacy_defaults
|
|
48
|
+
initialize_resilience_defaults
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def snapshot
|
|
52
|
+
errors = validation_errors
|
|
53
|
+
raise ConfigurationError, errors.join(", ") unless errors.empty?
|
|
54
|
+
|
|
55
|
+
Snapshot.new(to_hash)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def valid?
|
|
59
|
+
validation_errors.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validation_errors # rubocop:disable Metrics/AbcSize
|
|
63
|
+
errors = []
|
|
64
|
+
if enabled
|
|
65
|
+
errors << "project_id is required" if blank?(project_id)
|
|
66
|
+
errors << "project_key is required" if blank?(project_key)
|
|
67
|
+
errors.concat(host_errors)
|
|
68
|
+
end
|
|
69
|
+
errors << "timeout must be greater than zero" unless positive_number?(timeout)
|
|
70
|
+
errors << "open_timeout must be greater than zero" unless positive_number?(open_timeout)
|
|
71
|
+
errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
|
|
72
|
+
errors << "workers must be a positive integer" unless positive_integer?(workers)
|
|
73
|
+
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
74
|
+
errors.concat(resilience_errors)
|
|
75
|
+
errors.concat(privacy_errors)
|
|
76
|
+
errors.concat(context_errors)
|
|
77
|
+
errors
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def initialize_core_defaults
|
|
40
83
|
@project_id = nil
|
|
41
84
|
@project_key = nil
|
|
42
85
|
@host = nil
|
|
@@ -57,6 +100,12 @@ module Chronos
|
|
|
57
100
|
@user_agent = "chronos-ruby/#{Chronos::VERSION}"
|
|
58
101
|
@max_payload_size = 1_048_576
|
|
59
102
|
@gzip = false
|
|
103
|
+
@context_store = :thread_local
|
|
104
|
+
@breadcrumb_capacity = 20
|
|
105
|
+
@breadcrumb_max_bytes = 2048
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def initialize_privacy_defaults
|
|
60
109
|
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
61
110
|
@allowlist_keys = []
|
|
62
111
|
@filters = []
|
|
@@ -64,34 +113,65 @@ module Chronos
|
|
|
64
113
|
@anonymize_ip = true
|
|
65
114
|
end
|
|
66
115
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
|
|
116
|
+
def initialize_resilience_defaults
|
|
117
|
+
@max_retries = 3
|
|
118
|
+
@retry_base_interval = 0.5
|
|
119
|
+
@retry_max_interval = 30.0
|
|
120
|
+
@retry_jitter = 0.25
|
|
121
|
+
@backlog_size = 100
|
|
122
|
+
@circuit_failure_threshold = 5
|
|
123
|
+
@circuit_reset_timeout = 30.0
|
|
124
|
+
@remote_configuration = true
|
|
125
|
+
@remote_config_max_bytes = 4096
|
|
126
|
+
@sampling_rate = 1.0
|
|
127
|
+
@enabled_event_types = ["exception"]
|
|
128
|
+
@max_remote_send_interval = 60.0
|
|
129
|
+
end
|
|
70
130
|
|
|
71
|
-
|
|
131
|
+
def resilience_errors
|
|
132
|
+
errors = []
|
|
133
|
+
errors.concat(retry_errors)
|
|
134
|
+
errors.concat(backlog_and_circuit_errors)
|
|
135
|
+
errors.concat(remote_policy_errors)
|
|
136
|
+
errors
|
|
72
137
|
end
|
|
73
138
|
|
|
74
|
-
def
|
|
75
|
-
|
|
139
|
+
def retry_errors
|
|
140
|
+
errors = []
|
|
141
|
+
errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
|
|
142
|
+
errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
|
|
143
|
+
errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
|
|
144
|
+
if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
|
|
145
|
+
retry_max_interval < retry_base_interval
|
|
146
|
+
errors << "retry_max_interval must be greater than or equal to retry_base_interval"
|
|
147
|
+
end
|
|
148
|
+
errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
|
|
149
|
+
errors
|
|
76
150
|
end
|
|
77
151
|
|
|
78
|
-
def
|
|
152
|
+
def backlog_and_circuit_errors
|
|
79
153
|
errors = []
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
errors << "
|
|
83
|
-
errors.concat(host_errors)
|
|
154
|
+
errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
|
|
155
|
+
unless positive_integer?(circuit_failure_threshold)
|
|
156
|
+
errors << "circuit_failure_threshold must be a positive integer"
|
|
84
157
|
end
|
|
85
|
-
errors << "
|
|
86
|
-
errors << "open_timeout must be greater than zero" unless positive_number?(open_timeout)
|
|
87
|
-
errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
|
|
88
|
-
errors << "workers must be a positive integer" unless positive_integer?(workers)
|
|
89
|
-
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
90
|
-
errors.concat(privacy_errors)
|
|
158
|
+
errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
|
|
91
159
|
errors
|
|
92
160
|
end
|
|
93
161
|
|
|
94
|
-
|
|
162
|
+
def remote_policy_errors
|
|
163
|
+
errors = []
|
|
164
|
+
unless [true, false].include?(remote_configuration)
|
|
165
|
+
errors << "remote_configuration must be true or false"
|
|
166
|
+
end
|
|
167
|
+
errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
|
|
168
|
+
errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
|
|
169
|
+
unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
|
|
170
|
+
errors << "enabled_event_types must contain only String values"
|
|
171
|
+
end
|
|
172
|
+
errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
|
|
173
|
+
errors
|
|
174
|
+
end
|
|
95
175
|
|
|
96
176
|
def to_hash
|
|
97
177
|
ATTRIBUTES.each_with_object({}) do |attribute, values|
|
|
@@ -113,6 +193,20 @@ module Chronos
|
|
|
113
193
|
errors
|
|
114
194
|
end
|
|
115
195
|
|
|
196
|
+
def context_errors
|
|
197
|
+
errors = []
|
|
198
|
+
unless context_store == :thread_local || CONTEXT_STORE_METHODS.all? do |method_name|
|
|
199
|
+
context_store.respond_to?(method_name)
|
|
200
|
+
end
|
|
201
|
+
errors << "context_store must be :thread_local or implement get, set, clear, and with_context"
|
|
202
|
+
end
|
|
203
|
+
errors << "breadcrumb_capacity must be a positive integer" unless positive_integer?(breadcrumb_capacity)
|
|
204
|
+
unless breadcrumb_max_bytes.is_a?(Integer) && breadcrumb_max_bytes >= 128
|
|
205
|
+
errors << "breadcrumb_max_bytes must be an integer greater than or equal to 128"
|
|
206
|
+
end
|
|
207
|
+
errors
|
|
208
|
+
end
|
|
209
|
+
|
|
116
210
|
def filter_errors
|
|
117
211
|
return ["filters must be an array"] unless filters.is_a?(Array)
|
|
118
212
|
return [] if filters.all? { |filter| filter.respond_to?(:call) }
|
|
@@ -170,6 +264,14 @@ module Chronos
|
|
|
170
264
|
value.is_a?(Integer) && value > 0
|
|
171
265
|
end
|
|
172
266
|
|
|
267
|
+
def non_negative_integer?(value)
|
|
268
|
+
value.is_a?(Integer) && value >= 0
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def rate?(value)
|
|
272
|
+
value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
273
|
+
end
|
|
274
|
+
|
|
173
275
|
# Immutable configuration shared by all runtime components.
|
|
174
276
|
#
|
|
175
277
|
# @responsibility Expose validated settings without mutable containers.
|
|
@@ -196,7 +298,7 @@ module Chronos
|
|
|
196
298
|
private
|
|
197
299
|
|
|
198
300
|
def deep_freeze(value)
|
|
199
|
-
return value if value.respond_to?(:call)
|
|
301
|
+
return value if value.respond_to?(:call) || context_store?(value)
|
|
200
302
|
|
|
201
303
|
case value
|
|
202
304
|
when Hash
|
|
@@ -209,6 +311,12 @@ module Chronos
|
|
|
209
311
|
end
|
|
210
312
|
value.freeze
|
|
211
313
|
end
|
|
314
|
+
|
|
315
|
+
def context_store?(value)
|
|
316
|
+
CONTEXT_STORE_METHODS.all? { |method_name| value.respond_to?(method_name) }
|
|
317
|
+
rescue StandardError
|
|
318
|
+
false
|
|
319
|
+
end
|
|
212
320
|
end
|
|
213
321
|
end
|
|
214
322
|
end
|