chronos-ruby 0.2.0.pre.1 → 0.3.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.
@@ -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
@@ -31,12 +31,52 @@ module Chronos
31
31
  :queue_size, :workers, :enabled, :error_notifications,
32
32
  :ignored_environments, :proxy, :ssl_verify, :user_agent,
33
33
  :max_payload_size, :gzip, :blocklist_keys, :allowlist_keys,
34
- :filters, :hash_keys, :anonymize_ip
34
+ :filters, :hash_keys, :anonymize_ip, :max_retries,
35
+ :retry_base_interval, :retry_max_interval, :retry_jitter,
36
+ :backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
37
+ :remote_configuration, :remote_config_max_bytes, :sampling_rate,
38
+ :enabled_event_types, :max_remote_send_interval
35
39
  ].freeze
36
40
 
37
41
  attr_accessor(*ATTRIBUTES)
38
42
 
39
43
  def initialize
44
+ initialize_core_defaults
45
+ initialize_privacy_defaults
46
+ initialize_resilience_defaults
47
+ end
48
+
49
+ def snapshot
50
+ errors = validation_errors
51
+ raise ConfigurationError, errors.join(", ") unless errors.empty?
52
+
53
+ Snapshot.new(to_hash)
54
+ end
55
+
56
+ def valid?
57
+ validation_errors.empty?
58
+ end
59
+
60
+ def validation_errors
61
+ errors = []
62
+ if enabled
63
+ errors << "project_id is required" if blank?(project_id)
64
+ errors << "project_key is required" if blank?(project_key)
65
+ errors.concat(host_errors)
66
+ end
67
+ errors << "timeout must be greater than zero" unless positive_number?(timeout)
68
+ errors << "open_timeout must be greater than zero" unless positive_number?(open_timeout)
69
+ errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
70
+ errors << "workers must be a positive integer" unless positive_integer?(workers)
71
+ errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
72
+ errors.concat(resilience_errors)
73
+ errors.concat(privacy_errors)
74
+ errors
75
+ end
76
+
77
+ private
78
+
79
+ def initialize_core_defaults
40
80
  @project_id = nil
41
81
  @project_key = nil
42
82
  @host = nil
@@ -57,6 +97,9 @@ module Chronos
57
97
  @user_agent = "chronos-ruby/#{Chronos::VERSION}"
58
98
  @max_payload_size = 1_048_576
59
99
  @gzip = false
100
+ end
101
+
102
+ def initialize_privacy_defaults
60
103
  @blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
61
104
  @allowlist_keys = []
62
105
  @filters = []
@@ -64,34 +107,65 @@ module Chronos
64
107
  @anonymize_ip = true
65
108
  end
66
109
 
67
- def snapshot
68
- errors = validation_errors
69
- raise ConfigurationError, errors.join(", ") unless errors.empty?
110
+ def initialize_resilience_defaults
111
+ @max_retries = 3
112
+ @retry_base_interval = 0.5
113
+ @retry_max_interval = 30.0
114
+ @retry_jitter = 0.25
115
+ @backlog_size = 100
116
+ @circuit_failure_threshold = 5
117
+ @circuit_reset_timeout = 30.0
118
+ @remote_configuration = true
119
+ @remote_config_max_bytes = 4096
120
+ @sampling_rate = 1.0
121
+ @enabled_event_types = ["exception"]
122
+ @max_remote_send_interval = 60.0
123
+ end
70
124
 
71
- Snapshot.new(to_hash)
125
+ def resilience_errors
126
+ errors = []
127
+ errors.concat(retry_errors)
128
+ errors.concat(backlog_and_circuit_errors)
129
+ errors.concat(remote_policy_errors)
130
+ errors
72
131
  end
73
132
 
74
- def valid?
75
- validation_errors.empty?
133
+ def retry_errors
134
+ errors = []
135
+ errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
136
+ errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
137
+ errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
138
+ if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
139
+ retry_max_interval < retry_base_interval
140
+ errors << "retry_max_interval must be greater than or equal to retry_base_interval"
141
+ end
142
+ errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
143
+ errors
76
144
  end
77
145
 
78
- def validation_errors
146
+ def backlog_and_circuit_errors
79
147
  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)
148
+ errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
149
+ unless positive_integer?(circuit_failure_threshold)
150
+ errors << "circuit_failure_threshold must be a positive integer"
84
151
  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)
152
+ errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
91
153
  errors
92
154
  end
93
155
 
94
- private
156
+ def remote_policy_errors
157
+ errors = []
158
+ unless [true, false].include?(remote_configuration)
159
+ errors << "remote_configuration must be true or false"
160
+ end
161
+ errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
162
+ errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
163
+ unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
164
+ errors << "enabled_event_types must contain only String values"
165
+ end
166
+ errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
167
+ errors
168
+ end
95
169
 
96
170
  def to_hash
97
171
  ATTRIBUTES.each_with_object({}) do |attribute, values|
@@ -170,6 +244,14 @@ module Chronos
170
244
  value.is_a?(Integer) && value > 0
171
245
  end
172
246
 
247
+ def non_negative_integer?(value)
248
+ value.is_a?(Integer) && value >= 0
249
+ end
250
+
251
+ def rate?(value)
252
+ value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
253
+ end
254
+
173
255
  # Immutable configuration shared by all runtime components.
174
256
  #
175
257
  # @responsibility Expose validated settings without mutable containers.
@@ -43,13 +43,15 @@ module Chronos
43
43
  @clock = clock || proc { Time.now }
44
44
  @sanitizer = options[:sanitizer] || Sanitizer.new(config)
45
45
  @safe_serializer = options[:safe_serializer] || SafeSerializer.new
46
+ @max_payload_size = options[:max_payload_size] || proc { @config.max_payload_size }
46
47
  end
47
48
 
48
49
  def call(notice)
49
50
  envelope = @safe_serializer.call(@sanitizer.call(build_envelope(notice)))
50
51
  body = JSON.generate(envelope)
51
- body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @config.max_payload_size
52
- raise Error, "event exceeds max_payload_size" if body.bytesize > @config.max_payload_size
52
+ limit = @max_payload_size.call
53
+ body = JSON.generate(compact_envelope(envelope)) if body.bytesize > limit
54
+ raise Error, "event exceeds max_payload_size" if body.bytesize > limit
53
55
 
54
56
  SerializedEvent.new(notice.event_id, body)
55
57
  end
@@ -4,7 +4,7 @@ module Chronos
4
4
  #
5
5
  # @responsibility Accept events up to a limit and count accepted and dropped items.
6
6
  # @motivation Prevent telemetry bursts from growing application memory indefinitely.
7
- # @limits Version 0.2 drops the newest item when full and does not persist to disk.
7
+ # @limits Version 0.3 drops the newest item when full and does not persist to disk.
8
8
  # @collaborators WorkerPool.
9
9
  # @thread_safety Mutex and condition variable protect all mutable state.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6 and fork-aware callers.
@@ -0,0 +1,65 @@
1
+ module Chronos
2
+ module Internal
3
+ # Fixed-capacity retry storage for sanitized serialized events.
4
+ #
5
+ # @responsibility Retain failed deliveries in memory without unbounded growth.
6
+ # @motivation Allow later recovery while preserving the host application's memory limit.
7
+ # @limits It never writes to disk and accepts only SerializedEvent instances.
8
+ # @collaborators DeliveryPipeline and SerializedEvent.
9
+ # @thread_safety A mutex protects storage and counters.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # backlog.push(serialized_event)
13
+ # @errors Invalid event types raise before entering storage.
14
+ # @performance Push and shift are bounded by the configured capacity.
15
+ class MemoryBacklog
16
+ attr_reader :capacity
17
+
18
+ def initialize(capacity)
19
+ unless capacity.is_a?(Integer) && capacity >= 0
20
+ raise ArgumentError, "capacity must be a non-negative integer"
21
+ end
22
+
23
+ @capacity = capacity
24
+ @items = []
25
+ @accepted = 0
26
+ @dropped = 0
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ def push(event)
31
+ unless event.is_a?(Core::SerializedEvent)
32
+ raise ArgumentError, "backlog accepts only sanitized serialized events"
33
+ end
34
+
35
+ @mutex.synchronize do
36
+ if @items.length >= capacity
37
+ @dropped += 1
38
+ return false
39
+ end
40
+ @items << event
41
+ @accepted += 1
42
+ true
43
+ end
44
+ end
45
+
46
+ def shift
47
+ @mutex.synchronize { @items.shift }
48
+ end
49
+
50
+ def size
51
+ @mutex.synchronize { @items.size }
52
+ end
53
+
54
+ def empty?
55
+ size.zero?
56
+ end
57
+
58
+ def stats
59
+ @mutex.synchronize do
60
+ {:size => @items.size, :capacity => capacity, :accepted => @accepted, :dropped => @dropped}
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -4,8 +4,8 @@ module Chronos
4
4
  #
5
5
  # @responsibility Start workers on first use, deliver events, flush, and shut down predictably.
6
6
  # @motivation Keep serialization and network delivery outside the caller's critical path.
7
- # @limits Version 0.2 does not retry failed deliveries or persist a backlog.
8
- # @collaborators BoundedQueue, Transport, and SafeLogger.
7
+ # @limits Retry policy belongs to the delivery collaborator.
8
+ # @collaborators BoundedQueue, DeliveryPipeline, and SafeLogger.
9
9
  # @thread_safety Internal state is synchronized and active delivery is counted.
10
10
  # @compatibility Ruby 2.2.10 through Ruby 2.6; workers are recreated after fork.
11
11
  # @example
@@ -16,9 +16,9 @@ module Chronos
16
16
  class WorkerPool
17
17
  POLL_INTERVAL = 0.05
18
18
 
19
- def initialize(queue, transport, worker_count, logger = nil)
19
+ def initialize(queue, delivery, worker_count, logger = nil)
20
20
  @queue = queue
21
- @transport = transport
21
+ @delivery = delivery
22
22
  @worker_count = worker_count
23
23
  @logger = logger || SafeLogger.new(nil)
24
24
  @mutex = Mutex.new
@@ -59,7 +59,6 @@ module Chronos
59
59
  flushed = flush_without_reopening(timeout)
60
60
  @queue.close
61
61
  join_workers(timeout)
62
- @transport.close
63
62
  flushed
64
63
  rescue StandardError => error
65
64
  @logger.warn("Chronos worker shutdown failed: #{error.class}")
@@ -87,7 +86,7 @@ module Chronos
87
86
 
88
87
  increment_active
89
88
  begin
90
- @transport.send_event(event)
89
+ @delivery.send_event(event)
91
90
  rescue StandardError => error
92
91
  @logger.warn("Chronos worker contained #{error.class}")
93
92
  ensure
@@ -4,17 +4,18 @@ module Chronos
4
4
  #
5
5
  # @responsibility Describe delivery outcome and retry classification.
6
6
  # @motivation Keep HTTP implementation details outside the application layer.
7
- # @limits It does not schedule retries; retry is outside version 0.2.
7
+ # @limits It classifies outcomes but does not schedule retries.
8
8
  # @thread_safety Immutable after construction.
9
9
  # @compatibility Ruby 2.2.10 through Ruby 2.6.
10
10
  class TransportResult
11
- attr_reader :status, :status_code, :retry_after, :error
11
+ attr_reader :status, :status_code, :retry_after, :error, :remote_configuration
12
12
 
13
13
  def initialize(status, options = {})
14
14
  @status = status
15
15
  @status_code = options[:status_code]
16
16
  @retry_after = options[:retry_after]
17
17
  @error = options[:error]
18
+ @remote_configuration = options[:remote_configuration]
18
19
  freeze
19
20
  end
20
21
 
@@ -23,7 +24,7 @@ module Chronos
23
24
  end
24
25
 
25
26
  def retryable?
26
- [:rate_limited, :server_error, :network_error].include?(status)
27
+ [:request_timeout, :rate_limited, :server_error, :network_error].include?(status)
27
28
  end
28
29
  end
29
30
 
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
2
  # Current version of the legacy Chronos Ruby agent.
3
- VERSION = "0.2.0.pre.1".freeze
3
+ VERSION = "0.3.0.pre.1".freeze
4
4
  end
data/lib/chronos.rb CHANGED
@@ -18,8 +18,13 @@ require "chronos/core/payload_serializer"
18
18
  require "chronos/ports/transport"
19
19
  require "chronos/internal/safe_logger"
20
20
  require "chronos/internal/bounded_queue"
21
+ require "chronos/internal/memory_backlog"
21
22
  require "chronos/internal/worker_pool"
22
23
  require "chronos/adapters/net_http_transport"
24
+ require "chronos/application/retry_policy"
25
+ require "chronos/application/circuit_breaker"
26
+ require "chronos/application/remote_configuration"
27
+ require "chronos/application/delivery_pipeline"
23
28
  require "chronos/application/capture_exception"
24
29
  require "chronos/agent"
25
30
 
@@ -27,7 +32,7 @@ require "chronos/agent"
27
32
  #
28
33
  # @responsibility Configure the agent and expose its small lifecycle API.
29
34
  # @motivation Give applications a stable entry point while internals evolve.
30
- # @limits Version 0.2 captures Ruby exceptions manually; integrations arrive later.
35
+ # @limits Version 0.3 captures Ruby exceptions manually; integrations arrive later.
31
36
  # @collaborators Configuration and Agent.
32
37
  # @thread_safety Agent replacement and lookup are protected by a mutex.
33
38
  # @compatibility Ruby 2.2.10 through Ruby 2.6.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chronos-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.pre.1
4
+ version: 0.3.0.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antonio Jefferson
@@ -93,6 +93,7 @@ files:
93
93
  - docs/adr/ADR-004-bounded-queue.md
94
94
  - docs/adr/ADR-005-sanitize-before-backlog.md
95
95
  - docs/adr/ADR-006-versioned-event-contract.md
96
+ - docs/adr/ADR-011-bounded-resilience-and-remote-control.md
96
97
  - docs/architecture.md
97
98
  - docs/compatibility.md
98
99
  - docs/configuration.md
@@ -101,6 +102,8 @@ files:
101
102
  - docs/modules/async-queue.md
102
103
  - docs/modules/configuration.md
103
104
  - docs/modules/notice-pipeline.md
105
+ - docs/modules/remote-configuration.md
106
+ - docs/modules/retry-backlog.md
104
107
  - docs/modules/sanitization.md
105
108
  - docs/modules/serialization.md
106
109
  - docs/modules/transport.md
@@ -113,6 +116,10 @@ files:
113
116
  - lib/chronos/agent.rb
114
117
  - lib/chronos/application.rb
115
118
  - lib/chronos/application/capture_exception.rb
119
+ - lib/chronos/application/circuit_breaker.rb
120
+ - lib/chronos/application/delivery_pipeline.rb
121
+ - lib/chronos/application/remote_configuration.rb
122
+ - lib/chronos/application/retry_policy.rb
116
123
  - lib/chronos/configuration.rb
117
124
  - lib/chronos/core.rb
118
125
  - lib/chronos/core/backtrace_parser.rb
@@ -127,6 +134,7 @@ files:
127
134
  - lib/chronos/errors.rb
128
135
  - lib/chronos/internal.rb
129
136
  - lib/chronos/internal/bounded_queue.rb
137
+ - lib/chronos/internal/memory_backlog.rb
130
138
  - lib/chronos/internal/safe_logger.rb
131
139
  - lib/chronos/internal/worker_pool.rb
132
140
  - lib/chronos/ports.rb