chronos-ruby 0.4.0.pre.1 → 0.5.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,122 @@
1
+ module Chronos
2
+ module Internal
3
+ # Shared validation predicates for mutable Chronos configuration.
4
+ #
5
+ # @responsibility Validate resilience, privacy, context, Rails, and endpoint settings.
6
+ # @motivation Keep Configuration focused on option storage and snapshot creation.
7
+ # @limits It is mixed into Configuration and is not a public extension API.
8
+ # @collaborators Chronos::Configuration values.
9
+ # @thread_safety Validation reads one configuration instance without shared state.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @errors Invalid values become messages; malformed URLs are contained.
12
+ module ConfigurationValidation
13
+ private
14
+
15
+ def resilience_errors
16
+ retry_errors + backlog_and_circuit_errors + remote_policy_errors
17
+ end
18
+
19
+ def retry_errors
20
+ errors = []
21
+ errors << "max_retries must be a non-negative integer" unless non_negative_integer?(max_retries)
22
+ errors << "retry_base_interval must be greater than zero" unless positive_number?(retry_base_interval)
23
+ errors << "retry_max_interval must be greater than zero" unless positive_number?(retry_max_interval)
24
+ if positive_number?(retry_base_interval) && positive_number?(retry_max_interval) &&
25
+ retry_max_interval < retry_base_interval
26
+ errors << "retry_max_interval must be greater than or equal to retry_base_interval"
27
+ end
28
+ errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
29
+ errors
30
+ end
31
+
32
+ def backlog_and_circuit_errors
33
+ errors = []
34
+ errors << "backlog_size must be a non-negative integer" unless non_negative_integer?(backlog_size)
35
+ unless positive_integer?(circuit_failure_threshold)
36
+ errors << "circuit_failure_threshold must be a positive integer"
37
+ end
38
+ errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
39
+ errors
40
+ end
41
+
42
+ def remote_policy_errors
43
+ errors = []
44
+ errors << "remote_configuration must be true or false" unless [true, false].include?(remote_configuration)
45
+ errors << "remote_config_max_bytes must be a positive integer" unless positive_integer?(remote_config_max_bytes)
46
+ errors << "sampling_rate must be between zero and one" unless rate?(sampling_rate)
47
+ unless enabled_event_types.is_a?(Array) && enabled_event_types.all? { |value| value.is_a?(String) }
48
+ errors << "enabled_event_types must contain only String values"
49
+ end
50
+ errors << "max_remote_send_interval must be greater than zero" unless positive_number?(max_remote_send_interval)
51
+ errors
52
+ end
53
+
54
+ def privacy_errors
55
+ errors = []
56
+ errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
57
+ errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
58
+ errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
59
+ errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
60
+ errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
61
+ errors.concat(matcher_errors("hash_keys", hash_keys))
62
+ errors.concat(filter_errors)
63
+ errors.concat(anonymization_errors)
64
+ errors
65
+ end
66
+
67
+ def context_errors
68
+ errors = []
69
+ unless context_store == :thread_local || compatible_context_store?
70
+ errors << "context_store must be :thread_local or implement get, set, clear, and with_context"
71
+ end
72
+ errors << "breadcrumb_capacity must be a positive integer" unless positive_integer?(breadcrumb_capacity)
73
+ unless breadcrumb_max_bytes.is_a?(Integer) && breadcrumb_max_bytes >= 128
74
+ errors << "breadcrumb_max_bytes must be an integer greater than or equal to 128"
75
+ end
76
+ rails_boolean_errors.each { |error| errors << error }
77
+ errors
78
+ end
79
+
80
+ def compatible_context_store?
81
+ self.class::CONTEXT_STORE_METHODS.all? { |method_name| context_store.respond_to?(method_name) }
82
+ end
83
+
84
+ def rails_boolean_errors
85
+ names = [:rails_enabled, :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent]
86
+ names.reject { |name| [true, false].include?(public_send(name)) }.map do |name|
87
+ "#{name} must be true or false"
88
+ end
89
+ end
90
+
91
+ def filter_errors
92
+ return ["filters must be an array"] unless filters.is_a?(Array)
93
+ return [] if filters.all? { |filter| filter.respond_to?(:call) }
94
+
95
+ ["filters must contain only callable objects"]
96
+ end
97
+
98
+ def anonymization_errors
99
+ [true, false].include?(anonymize_ip) ? [] : ["anonymize_ip must be true or false"]
100
+ end
101
+
102
+ def matcher_errors(name, values)
103
+ return [] unless values.is_a?(Array)
104
+ return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
105
+
106
+ ["#{name} must contain only String, Symbol, or Regexp values"]
107
+ end
108
+
109
+ def host_errors
110
+ return ["host is required"] if blank?(host)
111
+
112
+ uri = URI.parse(host.to_s)
113
+ return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
114
+ return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
115
+
116
+ []
117
+ rescue URI::InvalidURIError
118
+ ["host must be a valid URL"]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -1,4 +1,5 @@
1
1
  require "uri"
2
+ require "chronos/configuration/validation"
2
3
 
3
4
  module Chronos
4
5
  # Mutable configuration used only while the Chronos agent is being set up.
@@ -19,6 +20,7 @@ module Chronos
19
20
  # config.host = 'https://chronos.example.com'
20
21
  # snapshot = config.snapshot
21
22
  class Configuration
23
+ include Internal::ConfigurationValidation
22
24
  DEFAULT_BLOCKLIST_KEYS = %w(
23
25
  password password_confirmation passwd secret api_key apikey authorization
24
26
  token access_token refresh_token private_key client_secret cookie set-cookie
@@ -37,7 +39,8 @@ module Chronos
37
39
  :backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
38
40
  :remote_configuration, :remote_config_max_bytes, :sampling_rate,
39
41
  :enabled_event_types, :max_remote_send_interval, :context_store,
40
- :breadcrumb_capacity, :breadcrumb_max_bytes
42
+ :breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
43
+ :rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
41
44
  ].freeze
42
45
 
43
46
  attr_accessor(*ATTRIBUTES)
@@ -46,6 +49,7 @@ module Chronos
46
49
  initialize_core_defaults
47
50
  initialize_privacy_defaults
48
51
  initialize_resilience_defaults
52
+ initialize_rails_defaults
49
53
  end
50
54
 
51
55
  def snapshot
@@ -105,6 +109,13 @@ module Chronos
105
109
  @breadcrumb_max_bytes = 2048
106
110
  end
107
111
 
112
+ def initialize_rails_defaults
113
+ @rails_enabled = true
114
+ @rails_capture_in_console = false
115
+ @rails_capture_in_test = false
116
+ @rails_capture_user_agent = false
117
+ end
118
+
108
119
  def initialize_privacy_defaults
109
120
  @blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
110
121
  @allowlist_keys = []
@@ -124,55 +135,10 @@ module Chronos
124
135
  @remote_configuration = true
125
136
  @remote_config_max_bytes = 4096
126
137
  @sampling_rate = 1.0
127
- @enabled_event_types = ["exception"]
138
+ @enabled_event_types = ["exception", "request", "query", "job", "cache"]
128
139
  @max_remote_send_interval = 60.0
129
140
  end
130
141
 
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
137
- end
138
-
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
150
- end
151
-
152
- def backlog_and_circuit_errors
153
- 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"
157
- end
158
- errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
159
- errors
160
- end
161
-
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
175
-
176
142
  def to_hash
177
143
  ATTRIBUTES.each_with_object({}) do |attribute, values|
178
144
  value = public_send(attribute)
@@ -180,53 +146,6 @@ module Chronos
180
146
  end
181
147
  end
182
148
 
183
- def privacy_errors
184
- errors = []
185
- errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
186
- errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
187
- errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
188
- errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
189
- errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
190
- errors.concat(matcher_errors("hash_keys", hash_keys))
191
- errors.concat(filter_errors)
192
- errors.concat(anonymization_errors)
193
- errors
194
- end
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
-
210
- def filter_errors
211
- return ["filters must be an array"] unless filters.is_a?(Array)
212
- return [] if filters.all? { |filter| filter.respond_to?(:call) }
213
-
214
- ["filters must contain only callable objects"]
215
- end
216
-
217
- def anonymization_errors
218
- return [] if anonymize_ip == true || anonymize_ip == false
219
-
220
- ["anonymize_ip must be true or false"]
221
- end
222
-
223
- def matcher_errors(name, values)
224
- return [] unless values.is_a?(Array)
225
- return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
226
-
227
- ["#{name} must contain only String, Symbol, or Regexp values"]
228
- end
229
-
230
149
  def deep_copy(value)
231
150
  case value
232
151
  when Hash
@@ -240,18 +159,6 @@ module Chronos
240
159
  end
241
160
  end
242
161
 
243
- def host_errors
244
- return ["host is required"] if blank?(host)
245
-
246
- uri = URI.parse(host.to_s)
247
- return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
248
- return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
249
-
250
- []
251
- rescue URI::InvalidURIError
252
- ["host must be a valid URL"]
253
- end
254
-
255
162
  def blank?(value)
256
163
  value.nil? || value.to_s.strip.empty?
257
164
  end
@@ -271,52 +178,7 @@ module Chronos
271
178
  def rate?(value)
272
179
  value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
273
180
  end
274
-
275
- # Immutable configuration shared by all runtime components.
276
- #
277
- # @responsibility Expose validated settings without mutable containers.
278
- # @motivation Keep capture behavior stable while multiple threads run.
279
- # @limits It cannot be edited after creation.
280
- # @thread_safety Safe to share between threads after construction.
281
- # @compatibility Ruby 2.2.10 through Ruby 2.6.
282
- class Snapshot
283
- attr_reader(*ATTRIBUTES)
284
-
285
- def initialize(values)
286
- ATTRIBUTES.each do |attribute|
287
- value = values[attribute]
288
- deep_freeze(value)
289
- instance_variable_set("@#{attribute}", value)
290
- end
291
- freeze
292
- end
293
-
294
- def enabled_for_environment?
295
- enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
296
- end
297
-
298
- private
299
-
300
- def deep_freeze(value)
301
- return value if value.respond_to?(:call) || context_store?(value)
302
-
303
- case value
304
- when Hash
305
- value.each do |key, child|
306
- deep_freeze(key)
307
- deep_freeze(child)
308
- end
309
- when Array
310
- value.each { |child| deep_freeze(child) }
311
- end
312
- value.freeze
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
320
- end
321
181
  end
322
182
  end
183
+
184
+ require "chronos/configuration/snapshot"
@@ -0,0 +1,106 @@
1
+ require "securerandom"
2
+ require "time"
3
+
4
+ module Chronos
5
+ module Core
6
+ # Immutable framework telemetry event normalized to the Chronos v1 envelope.
7
+ #
8
+ # @responsibility Carry a bounded event type, timestamp, context, and payload.
9
+ # @motivation Let integrations report metrics without depending on exception notices.
10
+ # @limits It does not sanitize, serialize, enqueue, or deliver itself.
11
+ # @collaborators TelemetrySerializer and framework integrations.
12
+ # @thread_safety Immutable after construction and safe to share.
13
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
14
+ # @example
15
+ # event = Chronos::Core::TelemetryEvent.new("request", {"duration_ms" => 12.0})
16
+ # @errors Unsupported types raise ArgumentError during construction.
17
+ # @performance Construction is linear in supplied context and payload size.
18
+ class TelemetryEvent
19
+ TYPES = %w(request query job cache).freeze
20
+
21
+ attr_reader :event_id, :event_type, :timestamp, :context, :payload
22
+
23
+ def initialize(event_type, payload = {}, context = {}, options = {})
24
+ type = event_type.to_s
25
+ raise ArgumentError, "unsupported telemetry event type" unless TYPES.include?(type)
26
+
27
+ clock = options[:clock] || proc { Time.now }
28
+ @event_id = (options[:event_id] || SecureRandom.uuid).to_s.freeze
29
+ @event_type = type.freeze
30
+ @timestamp = clock.call.utc.iso8601(6).freeze
31
+ @context = deep_freeze(context.is_a?(Hash) ? context : {})
32
+ @payload = deep_freeze(payload.is_a?(Hash) ? payload : {})
33
+ freeze
34
+ end
35
+
36
+ private
37
+
38
+ def deep_freeze(value)
39
+ if value.is_a?(Hash)
40
+ value.each do |key, child|
41
+ deep_freeze(key)
42
+ deep_freeze(child)
43
+ end
44
+ elsif value.is_a?(Array)
45
+ value.each { |child| deep_freeze(child) }
46
+ end
47
+ value.freeze
48
+ end
49
+ end
50
+
51
+ # Serializes framework telemetry into the common Chronos event envelope.
52
+ #
53
+ # @responsibility Sanitize telemetry, normalize JSON primitives, and enforce payload size.
54
+ # @motivation Give Rails subscribers the same privacy and delivery boundary as exceptions.
55
+ # @limits It accepts only TelemetryEvent values and does not perform delivery.
56
+ # @collaborators TelemetryEvent, Sanitizer, SafeSerializer, RuntimeInfo, and SerializedEvent.
57
+ # @thread_safety Stateless apart from immutable configuration.
58
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
59
+ # @example
60
+ # serialized = serializer.call(event)
61
+ # @errors Oversized events raise Chronos::Error and are contained by CaptureTelemetry.
62
+ # @performance Traversal and output bytes are bounded by existing serializer budgets.
63
+ class TelemetrySerializer
64
+ def initialize(config, clock = nil, options = {})
65
+ @config = config
66
+ @clock = clock || proc { Time.now }
67
+ @sanitizer = options[:sanitizer] || Sanitizer.new(config)
68
+ @safe_serializer = options[:safe_serializer] || SafeSerializer.new
69
+ @max_payload_size = options[:max_payload_size] || proc { @config.max_payload_size }
70
+ @runtime_info = RuntimeInfo.new
71
+ end
72
+
73
+ def call(event)
74
+ raise ArgumentError, "event must be a TelemetryEvent" unless event.is_a?(TelemetryEvent)
75
+
76
+ envelope = @safe_serializer.call(@sanitizer.call(build_envelope(event)))
77
+ body = JSON.generate(envelope)
78
+ body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @max_payload_size.call
79
+ raise Error, "event exceeds max_payload_size" if body.bytesize > @max_payload_size.call
80
+
81
+ SerializedEvent.new(event.event_id, body)
82
+ end
83
+
84
+ private
85
+
86
+ def build_envelope(event)
87
+ runtime = @runtime_info.call
88
+ {
89
+ "schema_version" => "1.0", "event_id" => event.event_id,
90
+ "event_type" => event.event_type, "occurred_at" => event.timestamp,
91
+ "sent_at" => @clock.call.utc.iso8601(6), "project_key" => @config.project_id,
92
+ "environment" => @config.environment,
93
+ "service" => {"name" => @config.service_name, "version" => @config.app_version,
94
+ "instance_id" => runtime[:host]},
95
+ "runtime" => runtime[:runtime], "context" => event.context, "payload" => event.payload
96
+ }
97
+ end
98
+
99
+ def compact_envelope(envelope)
100
+ envelope["context"] = {"_truncated" => true}
101
+ envelope["payload"] = {"_truncated" => true}
102
+ envelope
103
+ end
104
+ end
105
+ end
106
+ end
@@ -47,7 +47,11 @@ module Chronos
47
47
  end
48
48
 
49
49
  def notify_safely(error, context)
50
- @notifier.notify(error, context)
50
+ if @notifier.respond_to?(:notify_once)
51
+ @notifier.notify_once(error, context)
52
+ else
53
+ @notifier.notify(error, context)
54
+ end
51
55
  rescue StandardError
52
56
  false
53
57
  end
@@ -5,6 +5,6 @@ module Chronos
5
5
  # @motivation Keep framework loading optional for plain Ruby applications.
6
6
  # @limits Integrations may depend only on documented public agent behavior.
7
7
  # @thread_safety Each integration documents its own guarantees.
8
- # @compatibility Version 0.4 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
8
+ # @compatibility Version 0.5 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
9
9
  module Integrations; end
10
10
  end
@@ -0,0 +1,70 @@
1
+ module Chronos
2
+ module Rails
3
+ # Installs Rails middleware and subscribers exactly once per application.
4
+ #
5
+ # @responsibility Apply feature-detected integration hooks after Rails initializers load.
6
+ # @motivation Centralize version-neutral installation outside Railtie DSL code.
7
+ # @limits It does not configure credentials or depend on private Rails APIs.
8
+ # @collaborators Rails application middleware, NotificationsSubscriber, and Chronos facade.
9
+ # @thread_safety A mutex protects the per-application installation registry.
10
+ # @compatibility Rails 4.2 through Rails 5.2 without Zeitwerk.
11
+ # @example
12
+ # Chronos::Rails::Installer.new.install(Rails.application)
13
+ # @errors Missing optional Rails capabilities return false without affecting boot.
14
+ # @performance Installation is one-time; request work is delegated to bounded integrations.
15
+ class Installer
16
+ @mutex = Mutex.new
17
+ @applications = {}
18
+
19
+ class << self
20
+ attr_reader :mutex, :applications
21
+ end
22
+
23
+ def initialize(notifier = Chronos, subscriber = nil)
24
+ @notifier = notifier
25
+ @subscriber = subscriber || NotificationsSubscriber.new(notifier)
26
+ end
27
+
28
+ def install(application)
29
+ options = @notifier.rails_integration_options(environment, console?)
30
+ return false unless options[:enabled]
31
+
32
+ self.class.mutex.synchronize do
33
+ return false if self.class.applications[application.object_id]
34
+
35
+ install_middleware(application, options)
36
+ @subscriber.install
37
+ self.class.applications[application.object_id] = true
38
+ end
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def rails_version
45
+ defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version.to_s : "unknown"
46
+ end
47
+
48
+ private
49
+
50
+ def install_middleware(application, options)
51
+ middleware = application.config.middleware
52
+ return false unless middleware.respond_to?(:use)
53
+
54
+ middleware.use(
55
+ Chronos::Integrations::Rack::Middleware,
56
+ :include_user_agent => options[:include_user_agent]
57
+ )
58
+ true
59
+ end
60
+
61
+ def environment
62
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
63
+ end
64
+
65
+ def console?
66
+ defined?(::Rails::Console)
67
+ end
68
+ end
69
+ end
70
+ end