chronos-ruby 0.3.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/CHANGELOG.md +23 -0
- data/README.md +55 -17
- data/contracts/event-v1.schema.json +25 -11
- data/contracts/rack-context-v1.schema.json +43 -0
- data/docs/adr/ADR-012-rack-context-isolation.md +27 -0
- data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
- data/docs/architecture.md +17 -3
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +13 -2
- data/docs/data-collected.md +15 -2
- data/docs/modules/rack-context.md +59 -0
- data/docs/modules/rails-legacy.md +60 -0
- data/docs/modules/telemetry-events.md +9 -0
- data/docs/performance.md +28 -3
- data/docs/privacy-lgpd.md +13 -3
- data/docs/troubleshooting.md +18 -2
- data/lib/chronos/adapters/thread_local_context_store.rb +52 -0
- data/lib/chronos/agent.rb +97 -6
- data/lib/chronos/application/capture_exception.rb +2 -2
- 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 +22 -134
- data/lib/chronos/core/breadcrumb.rb +126 -0
- data/lib/chronos/core/telemetry_event.rb +106 -0
- data/lib/chronos/integrations/rack/middleware.rb +156 -0
- data/lib/chronos/integrations/rack.rb +12 -0
- data/lib/chronos/integrations.rb +10 -0
- data/lib/chronos/ports/context_store.rb +22 -0
- 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 +46 -1
- data/lib/generators/chronos/install/install_generator.rb +25 -0
- data/lib/generators/chronos/install/templates/chronos.rb +20 -0
- metadata +23 -1
|
@@ -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,11 +20,13 @@ 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
|
|
25
27
|
session credit_card card_number cvv cpf cnpj
|
|
26
28
|
).freeze
|
|
29
|
+
CONTEXT_STORE_METHODS = [:get, :set, :clear, :with_context].freeze
|
|
27
30
|
|
|
28
31
|
ATTRIBUTES = [
|
|
29
32
|
:project_id, :project_key, :host, :environment, :app_version,
|
|
@@ -35,7 +38,9 @@ module Chronos
|
|
|
35
38
|
:retry_base_interval, :retry_max_interval, :retry_jitter,
|
|
36
39
|
:backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
|
|
37
40
|
:remote_configuration, :remote_config_max_bytes, :sampling_rate,
|
|
38
|
-
:enabled_event_types, :max_remote_send_interval
|
|
41
|
+
:enabled_event_types, :max_remote_send_interval, :context_store,
|
|
42
|
+
:breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
|
|
43
|
+
:rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
|
|
39
44
|
].freeze
|
|
40
45
|
|
|
41
46
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -44,6 +49,7 @@ module Chronos
|
|
|
44
49
|
initialize_core_defaults
|
|
45
50
|
initialize_privacy_defaults
|
|
46
51
|
initialize_resilience_defaults
|
|
52
|
+
initialize_rails_defaults
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def snapshot
|
|
@@ -57,7 +63,7 @@ module Chronos
|
|
|
57
63
|
validation_errors.empty?
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
def validation_errors
|
|
66
|
+
def validation_errors # rubocop:disable Metrics/AbcSize
|
|
61
67
|
errors = []
|
|
62
68
|
if enabled
|
|
63
69
|
errors << "project_id is required" if blank?(project_id)
|
|
@@ -71,6 +77,7 @@ module Chronos
|
|
|
71
77
|
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
72
78
|
errors.concat(resilience_errors)
|
|
73
79
|
errors.concat(privacy_errors)
|
|
80
|
+
errors.concat(context_errors)
|
|
74
81
|
errors
|
|
75
82
|
end
|
|
76
83
|
|
|
@@ -97,6 +104,16 @@ module Chronos
|
|
|
97
104
|
@user_agent = "chronos-ruby/#{Chronos::VERSION}"
|
|
98
105
|
@max_payload_size = 1_048_576
|
|
99
106
|
@gzip = false
|
|
107
|
+
@context_store = :thread_local
|
|
108
|
+
@breadcrumb_capacity = 20
|
|
109
|
+
@breadcrumb_max_bytes = 2048
|
|
110
|
+
end
|
|
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
|
|
100
117
|
end
|
|
101
118
|
|
|
102
119
|
def initialize_privacy_defaults
|
|
@@ -118,55 +135,10 @@ module Chronos
|
|
|
118
135
|
@remote_configuration = true
|
|
119
136
|
@remote_config_max_bytes = 4096
|
|
120
137
|
@sampling_rate = 1.0
|
|
121
|
-
@enabled_event_types = ["exception"]
|
|
138
|
+
@enabled_event_types = ["exception", "request", "query", "job", "cache"]
|
|
122
139
|
@max_remote_send_interval = 60.0
|
|
123
140
|
end
|
|
124
141
|
|
|
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
|
|
131
|
-
end
|
|
132
|
-
|
|
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
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def backlog_and_circuit_errors
|
|
147
|
-
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"
|
|
151
|
-
end
|
|
152
|
-
errors << "circuit_reset_timeout must be greater than zero" unless positive_number?(circuit_reset_timeout)
|
|
153
|
-
errors
|
|
154
|
-
end
|
|
155
|
-
|
|
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
|
|
169
|
-
|
|
170
142
|
def to_hash
|
|
171
143
|
ATTRIBUTES.each_with_object({}) do |attribute, values|
|
|
172
144
|
value = public_send(attribute)
|
|
@@ -174,39 +146,6 @@ module Chronos
|
|
|
174
146
|
end
|
|
175
147
|
end
|
|
176
148
|
|
|
177
|
-
def privacy_errors
|
|
178
|
-
errors = []
|
|
179
|
-
errors << "blocklist_keys must be an array" unless blocklist_keys.is_a?(Array)
|
|
180
|
-
errors << "allowlist_keys must be an array" unless allowlist_keys.is_a?(Array)
|
|
181
|
-
errors << "hash_keys must be an array" unless hash_keys.is_a?(Array)
|
|
182
|
-
errors.concat(matcher_errors("blocklist_keys", blocklist_keys))
|
|
183
|
-
errors.concat(matcher_errors("allowlist_keys", allowlist_keys))
|
|
184
|
-
errors.concat(matcher_errors("hash_keys", hash_keys))
|
|
185
|
-
errors.concat(filter_errors)
|
|
186
|
-
errors.concat(anonymization_errors)
|
|
187
|
-
errors
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def filter_errors
|
|
191
|
-
return ["filters must be an array"] unless filters.is_a?(Array)
|
|
192
|
-
return [] if filters.all? { |filter| filter.respond_to?(:call) }
|
|
193
|
-
|
|
194
|
-
["filters must contain only callable objects"]
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
def anonymization_errors
|
|
198
|
-
return [] if anonymize_ip == true || anonymize_ip == false
|
|
199
|
-
|
|
200
|
-
["anonymize_ip must be true or false"]
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def matcher_errors(name, values)
|
|
204
|
-
return [] unless values.is_a?(Array)
|
|
205
|
-
return [] if values.all? { |value| value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Regexp) }
|
|
206
|
-
|
|
207
|
-
["#{name} must contain only String, Symbol, or Regexp values"]
|
|
208
|
-
end
|
|
209
|
-
|
|
210
149
|
def deep_copy(value)
|
|
211
150
|
case value
|
|
212
151
|
when Hash
|
|
@@ -220,18 +159,6 @@ module Chronos
|
|
|
220
159
|
end
|
|
221
160
|
end
|
|
222
161
|
|
|
223
|
-
def host_errors
|
|
224
|
-
return ["host is required"] if blank?(host)
|
|
225
|
-
|
|
226
|
-
uri = URI.parse(host.to_s)
|
|
227
|
-
return ["host must be an absolute HTTP or HTTPS URL"] unless uri.host && %w(http https).include?(uri.scheme)
|
|
228
|
-
return ["host must use HTTPS unless ssl_verify is explicitly disabled"] if uri.scheme != "https" && ssl_verify
|
|
229
|
-
|
|
230
|
-
[]
|
|
231
|
-
rescue URI::InvalidURIError
|
|
232
|
-
["host must be a valid URL"]
|
|
233
|
-
end
|
|
234
|
-
|
|
235
162
|
def blank?(value)
|
|
236
163
|
value.nil? || value.to_s.strip.empty?
|
|
237
164
|
end
|
|
@@ -251,46 +178,7 @@ module Chronos
|
|
|
251
178
|
def rate?(value)
|
|
252
179
|
value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
253
180
|
end
|
|
254
|
-
|
|
255
|
-
# Immutable configuration shared by all runtime components.
|
|
256
|
-
#
|
|
257
|
-
# @responsibility Expose validated settings without mutable containers.
|
|
258
|
-
# @motivation Keep capture behavior stable while multiple threads run.
|
|
259
|
-
# @limits It cannot be edited after creation.
|
|
260
|
-
# @thread_safety Safe to share between threads after construction.
|
|
261
|
-
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
262
|
-
class Snapshot
|
|
263
|
-
attr_reader(*ATTRIBUTES)
|
|
264
|
-
|
|
265
|
-
def initialize(values)
|
|
266
|
-
ATTRIBUTES.each do |attribute|
|
|
267
|
-
value = values[attribute]
|
|
268
|
-
deep_freeze(value)
|
|
269
|
-
instance_variable_set("@#{attribute}", value)
|
|
270
|
-
end
|
|
271
|
-
freeze
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
def enabled_for_environment?
|
|
275
|
-
enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
private
|
|
279
|
-
|
|
280
|
-
def deep_freeze(value)
|
|
281
|
-
return value if value.respond_to?(:call)
|
|
282
|
-
|
|
283
|
-
case value
|
|
284
|
-
when Hash
|
|
285
|
-
value.each do |key, child|
|
|
286
|
-
deep_freeze(key)
|
|
287
|
-
deep_freeze(child)
|
|
288
|
-
end
|
|
289
|
-
when Array
|
|
290
|
-
value.each { |child| deep_freeze(child) }
|
|
291
|
-
end
|
|
292
|
-
value.freeze
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
181
|
end
|
|
296
182
|
end
|
|
183
|
+
|
|
184
|
+
require "chronos/configuration/snapshot"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Chronos
|
|
5
|
+
module Core
|
|
6
|
+
# Immutable, bounded diagnostic marker attached to an execution.
|
|
7
|
+
#
|
|
8
|
+
# @responsibility Normalize one breadcrumb without retaining raw application objects.
|
|
9
|
+
# @motivation Preserve useful events leading to an exception with predictable memory use.
|
|
10
|
+
# @limits It does not capture logs, SQL, bodies, or HTTP calls automatically.
|
|
11
|
+
# @collaborators SafeSerializer and BreadcrumbBuffer.
|
|
12
|
+
# @thread_safety Immutable after construction.
|
|
13
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
14
|
+
# @example
|
|
15
|
+
# breadcrumb.to_h #=> {"category"=>"custom", ...}
|
|
16
|
+
# @errors Unserializable metadata is replaced by SafeSerializer placeholders.
|
|
17
|
+
# @performance Metadata traversal has strict depth, node, item, and byte limits.
|
|
18
|
+
class Breadcrumb
|
|
19
|
+
CATEGORIES = %w(custom log request query external_http cache job).freeze
|
|
20
|
+
|
|
21
|
+
def initialize(attributes, clock = nil, max_bytes = 2048)
|
|
22
|
+
attributes = {} unless attributes.is_a?(Hash)
|
|
23
|
+
clock ||= proc { Time.now }
|
|
24
|
+
serializer = SafeSerializer.new(
|
|
25
|
+
:max_depth => 5, :max_keys => 20, :max_items => 20,
|
|
26
|
+
:max_string_bytes => 512, :max_nodes => 100
|
|
27
|
+
)
|
|
28
|
+
@data = serializer.call(build_data(attributes, clock))
|
|
29
|
+
@data = compact_data(@data, max_bytes) if JSON.generate(@data).bytesize > max_bytes
|
|
30
|
+
deep_freeze(@data)
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_h
|
|
35
|
+
@data
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_data(attributes, clock)
|
|
41
|
+
category = value(attributes, :category).to_s
|
|
42
|
+
category = "custom" unless CATEGORIES.include?(category)
|
|
43
|
+
{
|
|
44
|
+
"category" => category,
|
|
45
|
+
"message" => value(attributes, :message).to_s,
|
|
46
|
+
"metadata" => value(attributes, :metadata) || {},
|
|
47
|
+
"timestamp" => clock.call.utc.iso8601(6)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def compact_data(data, max_bytes)
|
|
52
|
+
message_limit = [max_bytes / 4, 32].max
|
|
53
|
+
compacted = {
|
|
54
|
+
"category" => data["category"],
|
|
55
|
+
"message" => SafeSerializer.new.call(data["message"], :max_string_bytes => message_limit),
|
|
56
|
+
"metadata" => {"_truncated" => true},
|
|
57
|
+
"timestamp" => data["timestamp"]
|
|
58
|
+
}
|
|
59
|
+
trim_compacted_message(compacted, max_bytes)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def trim_compacted_message(compacted, max_bytes)
|
|
63
|
+
while JSON.generate(compacted).bytesize > max_bytes && !compacted["message"].empty?
|
|
64
|
+
length = [compacted["message"].bytesize - 16, 0].max
|
|
65
|
+
compacted["message"] = SafeSerializer.new.call(
|
|
66
|
+
compacted["message"].byteslice(0, length).to_s,
|
|
67
|
+
:max_string_bytes => length
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
compacted
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def value(attributes, key)
|
|
74
|
+
attributes.key?(key) ? attributes[key] : attributes[key.to_s]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def deep_freeze(value)
|
|
78
|
+
if value.is_a?(Hash)
|
|
79
|
+
value.each do |key, child|
|
|
80
|
+
deep_freeze(key)
|
|
81
|
+
deep_freeze(child)
|
|
82
|
+
end
|
|
83
|
+
elsif value.is_a?(Array)
|
|
84
|
+
value.each { |child| deep_freeze(child) }
|
|
85
|
+
end
|
|
86
|
+
value.freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fixed-size circular collection of execution breadcrumbs.
|
|
91
|
+
#
|
|
92
|
+
# @responsibility Retain only the newest bounded breadcrumbs for one execution.
|
|
93
|
+
# @motivation Prevent long requests or noisy instrumentation from growing memory indefinitely.
|
|
94
|
+
# @limits It is process memory only and does not collect events by itself.
|
|
95
|
+
# @collaborators Breadcrumb and Agent.
|
|
96
|
+
# @thread_safety Intended for one execution thread; snapshots are immutable values.
|
|
97
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
98
|
+
# @example
|
|
99
|
+
# buffer.add(:category => "custom", :message => "started")
|
|
100
|
+
# @errors Invalid attributes are normalized into a safe breadcrumb.
|
|
101
|
+
# @performance Add is constant time and storage never exceeds capacity.
|
|
102
|
+
class BreadcrumbBuffer
|
|
103
|
+
def initialize(capacity, max_bytes = 2048)
|
|
104
|
+
raise ArgumentError, "capacity must be a positive integer" unless capacity.is_a?(Integer) && capacity > 0
|
|
105
|
+
|
|
106
|
+
@capacity = capacity
|
|
107
|
+
@max_bytes = max_bytes
|
|
108
|
+
@items = []
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def add(attributes)
|
|
112
|
+
@items.shift if @items.length >= @capacity
|
|
113
|
+
@items << Breadcrumb.new(attributes, nil, @max_bytes)
|
|
114
|
+
true
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_a
|
|
118
|
+
@items.map(&:to_h)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def size
|
|
122
|
+
@items.size
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Integrations
|
|
5
|
+
module Rack
|
|
6
|
+
# Captures unhandled Rack exceptions while preserving Rack semantics.
|
|
7
|
+
#
|
|
8
|
+
# @responsibility Build bounded request context, notify Chronos, and re-raise the original exception.
|
|
9
|
+
# @motivation Provide automatic error capture without coupling the core to Rack.
|
|
10
|
+
# @limits It never reads rack.input, enumerates response bodies, or infers Rails routes.
|
|
11
|
+
# @collaborators Rack application and the Chronos facade or Agent.
|
|
12
|
+
# @thread_safety Shared instances keep no per-request mutable state.
|
|
13
|
+
# @compatibility Rack 1.x/2.x protocol; Ruby 2.2.10 through Ruby 2.6.
|
|
14
|
+
# @example
|
|
15
|
+
# use Chronos::Integrations::Rack::Middleware
|
|
16
|
+
# @errors Notification failures are contained; the application exception is always re-raised.
|
|
17
|
+
# @performance Adds bounded hash construction and monotonic-clock reads per request.
|
|
18
|
+
class Middleware
|
|
19
|
+
def initialize(app, options = {})
|
|
20
|
+
@app = app
|
|
21
|
+
@notifier = options[:notifier] || Chronos
|
|
22
|
+
@include_user_agent = options[:include_user_agent] || false
|
|
23
|
+
@clock = options[:clock] || proc { monotonic_time }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(env)
|
|
27
|
+
started_at = @clock.call
|
|
28
|
+
base = request_capture_context(env)
|
|
29
|
+
@notifier.with_context(base) do
|
|
30
|
+
add_request_breadcrumb("request started", base)
|
|
31
|
+
call_application(env, base, started_at)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def call_application(env, base, started_at)
|
|
38
|
+
response = @app.call(env)
|
|
39
|
+
status = response[0]
|
|
40
|
+
headers = response[1]
|
|
41
|
+
add_request_breadcrumb("request completed", dynamic_request_context(base, status, headers, started_at))
|
|
42
|
+
response
|
|
43
|
+
rescue Exception => error # rubocop:disable Lint/RescueException
|
|
44
|
+
context = dynamic_request_context(base, 500, nil, started_at)
|
|
45
|
+
notify_safely(error, context)
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def notify_safely(error, context)
|
|
50
|
+
if @notifier.respond_to?(:notify_once)
|
|
51
|
+
@notifier.notify_once(error, context)
|
|
52
|
+
else
|
|
53
|
+
@notifier.notify(error, context)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def request_capture_context(env)
|
|
60
|
+
request = request_values(env)
|
|
61
|
+
{
|
|
62
|
+
:context => {"request" => request, "trace_id" => trace_id(env)},
|
|
63
|
+
:parameters => parameters(env),
|
|
64
|
+
:user => hash_value(env["chronos.user"])
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request_values(env)
|
|
69
|
+
values = {
|
|
70
|
+
"method" => env["REQUEST_METHOD"].to_s,
|
|
71
|
+
"route" => normalized_route(env),
|
|
72
|
+
"request_id" => request_id(env),
|
|
73
|
+
"host" => (env["HTTP_HOST"] || env["SERVER_NAME"]).to_s,
|
|
74
|
+
"path" => env["PATH_INFO"].to_s,
|
|
75
|
+
"controller" => controller_action(env, "controller"),
|
|
76
|
+
"action" => controller_action(env, "action")
|
|
77
|
+
}
|
|
78
|
+
values["user_agent"] = env["HTTP_USER_AGENT"].to_s if @include_user_agent
|
|
79
|
+
values
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dynamic_request_context(base, status, headers, started_at)
|
|
83
|
+
request = base[:context]["request"].merge(
|
|
84
|
+
"status" => status.to_i,
|
|
85
|
+
"duration_ms" => ((@clock.call - started_at) * 1000.0).round(3),
|
|
86
|
+
"response_size" => response_size(headers)
|
|
87
|
+
)
|
|
88
|
+
{:context => base[:context].merge("request" => request)}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parameters(env)
|
|
92
|
+
result = {}
|
|
93
|
+
[env["rack.request.query_hash"], env["action_dispatch.request.query_parameters"],
|
|
94
|
+
env["action_dispatch.request.path_parameters"], env["chronos.parameters"]].each do |candidate|
|
|
95
|
+
result.merge!(candidate) if candidate.is_a?(Hash)
|
|
96
|
+
end
|
|
97
|
+
result
|
|
98
|
+
rescue StandardError
|
|
99
|
+
{}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalized_route(env)
|
|
103
|
+
explicit = env["chronos.route"] || env["action_dispatch.route_uri_pattern"]
|
|
104
|
+
return explicit.to_s unless explicit.to_s.empty?
|
|
105
|
+
|
|
106
|
+
env["PATH_INFO"].to_s.split("/").map { |part| dynamic_segment?(part) ? ":id" : part }.join("/")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def dynamic_segment?(segment)
|
|
110
|
+
segment =~ /\A\d+\z/ || segment =~ /\A[0-9a-f]{8}-[0-9a-f-]{27,}\z/i
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def controller_action(env, key)
|
|
114
|
+
explicit = env["chronos.#{key}"]
|
|
115
|
+
paths = env["action_dispatch.request.path_parameters"]
|
|
116
|
+
explicit || (paths[key] if paths.is_a?(Hash)) || (paths[key.to_sym] if paths.is_a?(Hash))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request_id(env)
|
|
120
|
+
env["chronos.request_id"] || env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def trace_id(env)
|
|
124
|
+
env["chronos.trace_id"] || SecureRandom.uuid
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def response_size(headers)
|
|
128
|
+
return nil unless headers.respond_to?(:each)
|
|
129
|
+
|
|
130
|
+
pair = headers.find { |key, _value| key.to_s.casecmp("content-length").zero? }
|
|
131
|
+
pair ? pair[1].to_i : nil
|
|
132
|
+
rescue StandardError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def add_request_breadcrumb(message, context)
|
|
137
|
+
request = context[:context]["request"]
|
|
138
|
+
@notifier.add_breadcrumb(
|
|
139
|
+
:category => "request", :message => message,
|
|
140
|
+
:metadata => {"method" => request["method"], "route" => request["route"], "status" => request["status"]}
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def hash_value(value)
|
|
145
|
+
value.is_a?(Hash) ? value : {}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def monotonic_time
|
|
149
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
Time.now.to_f
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Integrations
|
|
3
|
+
# Rack protocol integrations that do not require Rack at gem load time.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Namespace Rack-compatible middleware.
|
|
6
|
+
# @motivation Permit optional use without adding a runtime Rack dependency.
|
|
7
|
+
# @limits It does not provide Rails-specific route discovery.
|
|
8
|
+
# @thread_safety Middleware instances may be shared by concurrent Rack threads.
|
|
9
|
+
# @compatibility Rack 1.x and 2.x protocol shapes on Ruby 2.2.10 through Ruby 2.6.
|
|
10
|
+
module Rack; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
# Optional framework entry points kept outside the framework-independent core.
|
|
3
|
+
#
|
|
4
|
+
# @responsibility Namespace integration adapters such as Rack middleware.
|
|
5
|
+
# @motivation Keep framework loading optional for plain Ruby applications.
|
|
6
|
+
# @limits Integrations may depend only on documented public agent behavior.
|
|
7
|
+
# @thread_safety Each integration documents its own guarantees.
|
|
8
|
+
# @compatibility Version 0.5 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
|
|
9
|
+
module Integrations; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Ports
|
|
3
|
+
# Conceptual storage port for execution-scoped context.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Define get, set, clear, and scoped context behavior.
|
|
6
|
+
# @motivation Let integrations isolate request state without depending on a thread implementation.
|
|
7
|
+
# @limits The port does not prescribe thread, fiber, or distributed propagation semantics.
|
|
8
|
+
# @collaborators Agent and context-store adapters.
|
|
9
|
+
# @thread_safety Implementations must isolate concurrent executions.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# Chronos::Ports::ContextStore.compatible?(store) #=> true
|
|
13
|
+
# @errors Compatibility checks never invoke application methods.
|
|
14
|
+
module ContextStore
|
|
15
|
+
REQUIRED_METHODS = [:get, :set, :clear, :with_context].freeze
|
|
16
|
+
|
|
17
|
+
def self.compatible?(object)
|
|
18
|
+
REQUIRED_METHODS.all? { |method_name| object.respond_to?(method_name) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|