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.
- checksums.yaml +4 -4
- data/README.md +23 -9
- data/contracts/event-v1.schema.json +25 -11
- data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
- data/docs/architecture.md +7 -1
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +8 -2
- data/docs/data-collected.md +9 -2
- data/docs/modules/rails-legacy.md +60 -0
- data/docs/modules/telemetry-events.md +9 -0
- data/docs/performance.md +16 -3
- data/docs/privacy-lgpd.md +7 -3
- data/docs/troubleshooting.md +9 -1
- data/lib/chronos/agent.rb +43 -1
- data/lib/chronos/application/capture_telemetry.rb +37 -0
- data/lib/chronos/application/remote_configuration.rb +1 -1
- data/lib/chronos/configuration/snapshot.rb +53 -0
- data/lib/chronos/configuration/validation.rb +122 -0
- data/lib/chronos/configuration.rb +15 -153
- data/lib/chronos/core/telemetry_event.rb +106 -0
- data/lib/chronos/integrations/rack/middleware.rb +5 -1
- data/lib/chronos/integrations.rb +1 -1
- data/lib/chronos/rails/installer.rb +70 -0
- data/lib/chronos/rails/notifications_subscriber.rb +203 -0
- data/lib/chronos/rails/railtie.rb +21 -0
- data/lib/chronos/rails.rb +16 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +26 -1
- data/lib/generators/chronos/install/install_generator.rb +25 -0
- data/lib/generators/chronos/install/templates/chronos.rb +20 -0
- metadata +14 -1
|
@@ -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.
|
|
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
|
data/lib/chronos/integrations.rb
CHANGED
|
@@ -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.
|
|
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
|