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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +60 -19
  4. data/contracts/rack-context-v1.schema.json +43 -0
  5. data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
  6. data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
  7. data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
  8. data/docs/architecture.md +15 -6
  9. data/docs/compatibility.md +6 -6
  10. data/docs/configuration.md +22 -0
  11. data/docs/data-collected.md +8 -2
  12. data/docs/examples/plain-ruby.md +6 -0
  13. data/docs/modules/async-queue.md +6 -2
  14. data/docs/modules/notice-pipeline.md +2 -1
  15. data/docs/modules/rack-context.md +59 -0
  16. data/docs/modules/remote-configuration.md +54 -0
  17. data/docs/modules/retry-backlog.md +60 -0
  18. data/docs/modules/transport.md +5 -3
  19. data/docs/performance.md +39 -2
  20. data/docs/privacy-lgpd.md +10 -2
  21. data/docs/troubleshooting.md +10 -2
  22. data/lib/chronos/adapters/net_http_transport.rb +21 -3
  23. data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
  24. data/lib/chronos/agent.rb +68 -11
  25. data/lib/chronos/application/capture_exception.rb +20 -14
  26. data/lib/chronos/application/circuit_breaker.rb +70 -0
  27. data/lib/chronos/application/delivery_pipeline.rb +248 -0
  28. data/lib/chronos/application/remote_configuration.rb +173 -0
  29. data/lib/chronos/application/retry_policy.rb +57 -0
  30. data/lib/chronos/configuration.rb +128 -20
  31. data/lib/chronos/core/breadcrumb.rb +126 -0
  32. data/lib/chronos/core/payload_serializer.rb +4 -2
  33. data/lib/chronos/integrations/rack/middleware.rb +152 -0
  34. data/lib/chronos/integrations/rack.rb +12 -0
  35. data/lib/chronos/integrations.rb +10 -0
  36. data/lib/chronos/internal/bounded_queue.rb +1 -1
  37. data/lib/chronos/internal/memory_backlog.rb +65 -0
  38. data/lib/chronos/internal/worker_pool.rb +5 -6
  39. data/lib/chronos/ports/context_store.rb +22 -0
  40. data/lib/chronos/ports/transport.rb +4 -3
  41. data/lib/chronos/version.rb +1 -1
  42. data/lib/chronos.rb +26 -1
  43. 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 snapshot
68
- errors = validation_errors
69
- raise ConfigurationError, errors.join(", ") unless errors.empty?
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
- Snapshot.new(to_hash)
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 valid?
75
- validation_errors.empty?
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 validation_errors
152
+ def backlog_and_circuit_errors
79
153
  errors = []
80
- if enabled
81
- errors << "project_id is required" if blank?(project_id)
82
- errors << "project_key is required" if blank?(project_key)
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 << "timeout must be greater than zero" unless positive_number?(timeout)
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
- private
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