chronos-ruby 0.4.0.pre.1 → 0.6.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +33 -9
  4. data/contracts/event-v1.schema.json +25 -11
  5. data/contracts/sidekiq-job-v1.schema.json +21 -0
  6. data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
  7. data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
  8. data/docs/architecture.md +12 -1
  9. data/docs/compatibility.md +12 -6
  10. data/docs/configuration.md +8 -2
  11. data/docs/data-collected.md +14 -2
  12. data/docs/modules/rails-legacy.md +60 -0
  13. data/docs/modules/sidekiq-legacy.md +23 -0
  14. data/docs/modules/telemetry-events.md +9 -0
  15. data/docs/performance.md +27 -3
  16. data/docs/privacy-lgpd.md +12 -3
  17. data/docs/troubleshooting.md +17 -1
  18. data/lib/chronos/agent.rb +58 -1
  19. data/lib/chronos/application/capture_telemetry.rb +37 -0
  20. data/lib/chronos/application/remote_configuration.rb +1 -1
  21. data/lib/chronos/configuration/snapshot.rb +53 -0
  22. data/lib/chronos/configuration/validation.rb +122 -0
  23. data/lib/chronos/configuration.rb +15 -153
  24. data/lib/chronos/core/telemetry_event.rb +106 -0
  25. data/lib/chronos/integrations/job_payload.rb +72 -0
  26. data/lib/chronos/integrations/rack/middleware.rb +5 -1
  27. data/lib/chronos/integrations/sidekiq.rb +258 -0
  28. data/lib/chronos/integrations.rb +1 -1
  29. data/lib/chronos/internal/worker_pool.rb +19 -2
  30. data/lib/chronos/rails/installer.rb +70 -0
  31. data/lib/chronos/rails/notifications_subscriber.rb +203 -0
  32. data/lib/chronos/rails/railtie.rb +21 -0
  33. data/lib/chronos/rails.rb +16 -0
  34. data/lib/chronos/sidekiq.rb +12 -0
  35. data/lib/chronos/version.rb +1 -1
  36. data/lib/chronos.rb +34 -1
  37. data/lib/generators/chronos/install/install_generator.rb +25 -0
  38. data/lib/generators/chronos/install/templates/chronos.rb +20 -0
  39. metadata +21 -2
data/lib/chronos/agent.rb CHANGED
@@ -36,7 +36,7 @@ module Chronos
36
36
  @logger,
37
37
  pipeline_options
38
38
  )
39
- @capture = options[:capture] || Application::CaptureException.new(config, @delivery_pipeline, @logger)
39
+ initialize_capture(options)
40
40
  end
41
41
 
42
42
  def notify(exception, context = {})
@@ -63,6 +63,46 @@ module Chronos
63
63
  false
64
64
  end
65
65
 
66
+ def record_event(event_type, payload = {}, context = {})
67
+ @telemetry.call(event_type, payload, telemetry_context(context))
68
+ end
69
+
70
+ # Returns the correlation subset safe for an integration-owned process boundary.
71
+ def propagation_context
72
+ current = context_hash(@context_store.get)
73
+ nested = context_hash(current[:context] || current["context"])
74
+ request = context_hash(nested["request"] || nested[:request])
75
+ values = {
76
+ "trace_id" => nested["trace_id"] || nested[:trace_id],
77
+ "request_id" => nested["request_id"] || nested[:request_id] ||
78
+ request["request_id"] || request[:request_id]
79
+ }
80
+ values.delete_if { |_key, value| value.to_s.empty? }
81
+ rescue StandardError
82
+ {}
83
+ end
84
+
85
+ def notify_once(exception, context = {})
86
+ execution = @context_store.get
87
+ captured = execution[:__chronos_captured_exceptions] || {}
88
+ keys = [[:object, exception.object_id], [:message, exception.message.to_s]]
89
+ return false if keys.any? { |key| captured[key] }
90
+
91
+ keys.each { |key| captured[key] = true }
92
+ @context_store.set(execution.merge(:__chronos_captured_exceptions => captured))
93
+ notify(exception, context)
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ def rails_integration_options(environment = nil, console = false)
99
+ current_environment = (environment || @config.environment).to_s
100
+ enabled = @config.rails_enabled
101
+ enabled = false if console && !@config.rails_capture_in_console
102
+ enabled = false if current_environment == "test" && !@config.rails_capture_in_test
103
+ {:enabled => enabled, :include_user_agent => @config.rails_capture_user_agent}
104
+ end
105
+
66
106
  def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
67
107
  @delivery_pipeline.flush(timeout)
68
108
  rescue StandardError => error
@@ -84,6 +124,11 @@ module Chronos
84
124
 
85
125
  private
86
126
 
127
+ def initialize_capture(options)
128
+ @capture = options[:capture] || Application::CaptureException.new(@config, @delivery_pipeline, @logger)
129
+ @telemetry = options[:telemetry] || Application::CaptureTelemetry.new(@config, @delivery_pipeline, @logger)
130
+ end
131
+
87
132
  def build_context_store(strategy)
88
133
  return Adapters::ThreadLocalContextStore.new if strategy == :thread_local
89
134
 
@@ -92,6 +137,8 @@ module Chronos
92
137
 
93
138
  def context_for_capture(additional)
94
139
  merged = deep_merge(context_hash(@context_store.get), context_hash(additional))
140
+ merged.delete(:__chronos_captured_exceptions)
141
+ merged.delete("__chronos_captured_exceptions")
95
142
  buffer = merged.delete(:__chronos_breadcrumbs) || merged.delete("__chronos_breadcrumbs")
96
143
  if buffer.respond_to?(:to_a)
97
144
  merged[:context] = context_hash(merged[:context]).merge("breadcrumbs" => buffer.to_a)
@@ -101,6 +148,16 @@ module Chronos
101
148
  context_hash(additional)
102
149
  end
103
150
 
151
+ def telemetry_context(additional)
152
+ merged = context_for_capture(additional)
153
+ context = context_hash(merged.delete(:context) || merged.delete("context"))
154
+ parameters = merged.delete(:parameters) || merged.delete("parameters")
155
+ user = merged.delete(:user) || merged.delete("user")
156
+ context["parameters"] = parameters if parameters.is_a?(Hash) && !parameters.empty?
157
+ context["user"] = user if user.is_a?(Hash) && !user.empty?
158
+ context.merge(merged)
159
+ end
160
+
104
161
  def deep_merge(left, right)
105
162
  left.merge(right) do |_key, old_value, new_value|
106
163
  old_value.is_a?(Hash) && new_value.is_a?(Hash) ? deep_merge(old_value, new_value) : new_value
@@ -0,0 +1,37 @@
1
+ module Chronos
2
+ module Application
3
+ # Coordinates bounded non-exception telemetry capture.
4
+ #
5
+ # @responsibility Build, sanitize, serialize, and enqueue integration events.
6
+ # @motivation Keep Rails notification policy outside transport and domain objects.
7
+ # @limits It handles request, query, job, and cache events only.
8
+ # @collaborators TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
9
+ # @thread_safety Calls own event state and may execute concurrently.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
11
+ # @example
12
+ # capture.call("query", {"duration_ms" => 3.2})
13
+ # @errors Internal StandardError failures are contained and logged.
14
+ # @performance Local normalization is bounded before asynchronous queue insertion.
15
+ class CaptureTelemetry
16
+ def initialize(config, delivery_pipeline, logger = nil, options = {})
17
+ @config = config
18
+ @delivery_pipeline = delivery_pipeline
19
+ @logger = logger || Internal::SafeLogger.new(config.logger)
20
+ @serializer = options[:serializer] || Core::TelemetrySerializer.new(
21
+ config, nil, :max_payload_size => proc { @delivery_pipeline.max_payload_size }
22
+ )
23
+ end
24
+
25
+ def call(event_type, payload = {}, context = {})
26
+ return false unless @config.enabled_for_environment?
27
+ return false unless @delivery_pipeline.capture_allowed?(event_type)
28
+
29
+ event = Core::TelemetryEvent.new(event_type, payload, context)
30
+ @delivery_pipeline.enqueue(@serializer.call(event))
31
+ rescue StandardError => error
32
+ @logger.warn("Chronos telemetry capture failed: #{error.class}")
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
@@ -13,7 +13,7 @@ module Chronos
13
13
  # @errors Invalid documents are ignored and return false.
14
14
  # @performance Validation is bounded by configured document and list limits.
15
15
  class RemoteConfiguration
16
- SUPPORTED_EVENT_TYPES = ["exception"].freeze
16
+ SUPPORTED_EVENT_TYPES = ["exception", "request", "query", "job", "cache"].freeze
17
17
  MAX_IGNORED_FINGERPRINTS = 100
18
18
  MAX_FINGERPRINT_BYTES = 256
19
19
  MIN_PAYLOAD_SIZE = 256
@@ -0,0 +1,53 @@
1
+ module Chronos
2
+ # Immutable configuration shared by all runtime components.
3
+ #
4
+ # @responsibility Expose validated settings without mutable containers.
5
+ # @motivation Keep capture behavior stable while multiple threads run.
6
+ # @limits It cannot be edited after creation.
7
+ # @collaborators Configuration and runtime services.
8
+ # @thread_safety Safe to share between threads after construction.
9
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
10
+ # @example
11
+ # snapshot.enabled_for_environment? #=> true
12
+ # @errors Construction occurs only after Configuration validation.
13
+ # @performance Deep freezing is paid once during configuration.
14
+ Configuration::Snapshot = Class.new do
15
+ attr_reader(*Configuration::ATTRIBUTES)
16
+
17
+ def initialize(values)
18
+ Configuration::ATTRIBUTES.each do |attribute|
19
+ value = values[attribute]
20
+ deep_freeze(value)
21
+ instance_variable_set("@#{attribute}", value)
22
+ end
23
+ freeze
24
+ end
25
+
26
+ def enabled_for_environment?
27
+ enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
28
+ end
29
+
30
+ private # rubocop:disable Layout/AccessModifierIndentation
31
+
32
+ def deep_freeze(value)
33
+ return value if value.respond_to?(:call) || context_store?(value)
34
+
35
+ case value
36
+ when Hash
37
+ value.each do |key, child|
38
+ deep_freeze(key)
39
+ deep_freeze(child)
40
+ end
41
+ when Array
42
+ value.each { |child| deep_freeze(child) }
43
+ end
44
+ value.freeze
45
+ end
46
+
47
+ def context_store?(value)
48
+ Configuration::CONTEXT_STORE_METHODS.all? { |method_name| value.respond_to?(method_name) }
49
+ rescue StandardError
50
+ false
51
+ end
52
+ end
53
+ end
@@ -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