chronos-ruby 0.1.0.pre.2 → 0.3.0.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +51 -21
- data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
- data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
- data/docs/architecture.md +5 -4
- data/docs/compatibility.md +6 -6
- data/docs/configuration.md +27 -1
- data/docs/data-collected.md +10 -7
- data/docs/examples/plain-ruby.md +14 -0
- data/docs/modules/async-queue.md +6 -2
- data/docs/modules/notice-pipeline.md +6 -3
- data/docs/modules/remote-configuration.md +54 -0
- data/docs/modules/retry-backlog.md +60 -0
- data/docs/modules/sanitization.md +19 -0
- data/docs/modules/serialization.md +9 -0
- data/docs/modules/transport.md +5 -3
- data/docs/performance.md +39 -2
- data/docs/privacy-lgpd.md +80 -10
- data/docs/troubleshooting.md +2 -2
- data/lib/chronos/adapters/net_http_transport.rb +21 -3
- data/lib/chronos/agent.rb +16 -8
- data/lib/chronos/application/capture_exception.rb +20 -14
- data/lib/chronos/application/circuit_breaker.rb +70 -0
- data/lib/chronos/application/delivery_pipeline.rb +248 -0
- data/lib/chronos/application/remote_configuration.rb +173 -0
- data/lib/chronos/application/retry_policy.rb +57 -0
- data/lib/chronos/configuration.rb +178 -21
- data/lib/chronos/core/notice.rb +1 -1
- data/lib/chronos/core/payload_serializer.rb +39 -89
- data/lib/chronos/core/runtime_info.rb +1 -1
- data/lib/chronos/core/safe_serializer.rb +119 -0
- data/lib/chronos/core/sanitizer.rb +161 -0
- data/lib/chronos/core/sensitive_value_filter.rb +118 -0
- data/lib/chronos/internal/bounded_queue.rb +1 -1
- data/lib/chronos/internal/memory_backlog.rb +65 -0
- data/lib/chronos/internal/worker_pool.rb +5 -6
- data/lib/chronos/ports/transport.rb +4 -3
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +9 -1
- metadata +30 -11
|
@@ -19,17 +19,64 @@ module Chronos
|
|
|
19
19
|
# config.host = 'https://chronos.example.com'
|
|
20
20
|
# snapshot = config.snapshot
|
|
21
21
|
class Configuration
|
|
22
|
+
DEFAULT_BLOCKLIST_KEYS = %w(
|
|
23
|
+
password password_confirmation passwd secret api_key apikey authorization
|
|
24
|
+
token access_token refresh_token private_key client_secret cookie set-cookie
|
|
25
|
+
session credit_card card_number cvv cpf cnpj
|
|
26
|
+
).freeze
|
|
27
|
+
|
|
22
28
|
ATTRIBUTES = [
|
|
23
29
|
:project_id, :project_key, :host, :environment, :app_version,
|
|
24
30
|
:service_name, :root_directory, :logger, :timeout, :open_timeout,
|
|
25
31
|
:queue_size, :workers, :enabled, :error_notifications,
|
|
26
32
|
:ignored_environments, :proxy, :ssl_verify, :user_agent,
|
|
27
|
-
:max_payload_size, :gzip
|
|
33
|
+
:max_payload_size, :gzip, :blocklist_keys, :allowlist_keys,
|
|
34
|
+
:filters, :hash_keys, :anonymize_ip, :max_retries,
|
|
35
|
+
:retry_base_interval, :retry_max_interval, :retry_jitter,
|
|
36
|
+
:backlog_size, :circuit_failure_threshold, :circuit_reset_timeout,
|
|
37
|
+
:remote_configuration, :remote_config_max_bytes, :sampling_rate,
|
|
38
|
+
:enabled_event_types, :max_remote_send_interval
|
|
28
39
|
].freeze
|
|
29
40
|
|
|
30
41
|
attr_accessor(*ATTRIBUTES)
|
|
31
42
|
|
|
32
43
|
def initialize
|
|
44
|
+
initialize_core_defaults
|
|
45
|
+
initialize_privacy_defaults
|
|
46
|
+
initialize_resilience_defaults
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def snapshot
|
|
50
|
+
errors = validation_errors
|
|
51
|
+
raise ConfigurationError, errors.join(", ") unless errors.empty?
|
|
52
|
+
|
|
53
|
+
Snapshot.new(to_hash)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid?
|
|
57
|
+
validation_errors.empty?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validation_errors
|
|
61
|
+
errors = []
|
|
62
|
+
if enabled
|
|
63
|
+
errors << "project_id is required" if blank?(project_id)
|
|
64
|
+
errors << "project_key is required" if blank?(project_key)
|
|
65
|
+
errors.concat(host_errors)
|
|
66
|
+
end
|
|
67
|
+
errors << "timeout must be greater than zero" unless positive_number?(timeout)
|
|
68
|
+
errors << "open_timeout must be greater than zero" unless positive_number?(open_timeout)
|
|
69
|
+
errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
|
|
70
|
+
errors << "workers must be a positive integer" unless positive_integer?(workers)
|
|
71
|
+
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
72
|
+
errors.concat(resilience_errors)
|
|
73
|
+
errors.concat(privacy_errors)
|
|
74
|
+
errors
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def initialize_core_defaults
|
|
33
80
|
@project_id = nil
|
|
34
81
|
@project_key = nil
|
|
35
82
|
@host = nil
|
|
@@ -52,39 +99,124 @@ module Chronos
|
|
|
52
99
|
@gzip = false
|
|
53
100
|
end
|
|
54
101
|
|
|
55
|
-
def
|
|
56
|
-
|
|
57
|
-
|
|
102
|
+
def initialize_privacy_defaults
|
|
103
|
+
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
104
|
+
@allowlist_keys = []
|
|
105
|
+
@filters = []
|
|
106
|
+
@hash_keys = []
|
|
107
|
+
@anonymize_ip = true
|
|
108
|
+
end
|
|
58
109
|
|
|
59
|
-
|
|
110
|
+
def initialize_resilience_defaults
|
|
111
|
+
@max_retries = 3
|
|
112
|
+
@retry_base_interval = 0.5
|
|
113
|
+
@retry_max_interval = 30.0
|
|
114
|
+
@retry_jitter = 0.25
|
|
115
|
+
@backlog_size = 100
|
|
116
|
+
@circuit_failure_threshold = 5
|
|
117
|
+
@circuit_reset_timeout = 30.0
|
|
118
|
+
@remote_configuration = true
|
|
119
|
+
@remote_config_max_bytes = 4096
|
|
120
|
+
@sampling_rate = 1.0
|
|
121
|
+
@enabled_event_types = ["exception"]
|
|
122
|
+
@max_remote_send_interval = 60.0
|
|
60
123
|
end
|
|
61
124
|
|
|
62
|
-
def
|
|
63
|
-
|
|
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
|
|
64
131
|
end
|
|
65
132
|
|
|
66
|
-
def
|
|
133
|
+
def retry_errors
|
|
67
134
|
errors = []
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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"
|
|
72
141
|
end
|
|
73
|
-
errors << "
|
|
74
|
-
errors << "open_timeout must be greater than zero" unless positive_number?(open_timeout)
|
|
75
|
-
errors << "queue_size must be a positive integer" unless positive_integer?(queue_size)
|
|
76
|
-
errors << "workers must be a positive integer" unless positive_integer?(workers)
|
|
77
|
-
errors << "max_payload_size must be a positive integer" unless positive_integer?(max_payload_size)
|
|
142
|
+
errors << "retry_jitter must be between zero and one" unless rate?(retry_jitter)
|
|
78
143
|
errors
|
|
79
144
|
end
|
|
80
145
|
|
|
81
|
-
|
|
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
|
|
82
169
|
|
|
83
170
|
def to_hash
|
|
84
171
|
ATTRIBUTES.each_with_object({}) do |attribute, values|
|
|
85
172
|
value = public_send(attribute)
|
|
86
|
-
|
|
87
|
-
|
|
173
|
+
values[attribute] = deep_copy(value)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
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
|
+
def deep_copy(value)
|
|
211
|
+
case value
|
|
212
|
+
when Hash
|
|
213
|
+
value.each_with_object({}) { |(key, child), result| result[deep_copy(key)] = deep_copy(child) }
|
|
214
|
+
when Array
|
|
215
|
+
value.map { |child| deep_copy(child) }
|
|
216
|
+
when String, Regexp
|
|
217
|
+
value.dup
|
|
218
|
+
else
|
|
219
|
+
value
|
|
88
220
|
end
|
|
89
221
|
end
|
|
90
222
|
|
|
@@ -112,6 +244,14 @@ module Chronos
|
|
|
112
244
|
value.is_a?(Integer) && value > 0
|
|
113
245
|
end
|
|
114
246
|
|
|
247
|
+
def non_negative_integer?(value)
|
|
248
|
+
value.is_a?(Integer) && value >= 0
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def rate?(value)
|
|
252
|
+
value.is_a?(Numeric) && value >= 0.0 && value <= 1.0
|
|
253
|
+
end
|
|
254
|
+
|
|
115
255
|
# Immutable configuration shared by all runtime components.
|
|
116
256
|
#
|
|
117
257
|
# @responsibility Expose validated settings without mutable containers.
|
|
@@ -125,7 +265,7 @@ module Chronos
|
|
|
125
265
|
def initialize(values)
|
|
126
266
|
ATTRIBUTES.each do |attribute|
|
|
127
267
|
value = values[attribute]
|
|
128
|
-
|
|
268
|
+
deep_freeze(value)
|
|
129
269
|
instance_variable_set("@#{attribute}", value)
|
|
130
270
|
end
|
|
131
271
|
freeze
|
|
@@ -134,6 +274,23 @@ module Chronos
|
|
|
134
274
|
def enabled_for_environment?
|
|
135
275
|
enabled && !ignored_environments.map(&:to_s).include?(environment.to_s)
|
|
136
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
|
|
137
294
|
end
|
|
138
295
|
end
|
|
139
296
|
end
|
data/lib/chronos/core/notice.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Chronos
|
|
|
4
4
|
#
|
|
5
5
|
# @responsibility Carry exception and diagnostic context through the pipeline.
|
|
6
6
|
# @motivation Keep transport details separate from exception normalization.
|
|
7
|
-
# @limits It does not sanitize, serialize, enqueue, or send itself.
|
|
7
|
+
# @limits It does not sanitize, serialize, enqueue, or send itself; raw values live only in memory.
|
|
8
8
|
# @collaborators NoticeBuilder and PayloadSerializer.
|
|
9
9
|
# @thread_safety Immutable after construction and safe to share.
|
|
10
10
|
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
|
|
@@ -26,33 +26,32 @@ module Chronos
|
|
|
26
26
|
|
|
27
27
|
# Converts a Notice into the versioned Chronos JSON envelope.
|
|
28
28
|
#
|
|
29
|
-
# @responsibility
|
|
30
|
-
# @motivation
|
|
31
|
-
# @limits
|
|
32
|
-
# @collaborators Notice and SerializedEvent.
|
|
29
|
+
# @responsibility Sanitize an event, normalize it to JSON primitives, and enforce size limits.
|
|
30
|
+
# @motivation Ensure sensitive or unsafe application values never reach transport unchanged.
|
|
31
|
+
# @limits Detection is bounded and does not replace application-specific data governance.
|
|
32
|
+
# @collaborators Notice, Sanitizer, SafeSerializer, and SerializedEvent.
|
|
33
33
|
# @thread_safety Stateless apart from immutable configuration.
|
|
34
34
|
# @compatibility Uses the JSON standard library available on Ruby 2.2.10.
|
|
35
35
|
# @example
|
|
36
36
|
# event = serializer.call(notice)
|
|
37
37
|
# event.body #=> "{...}"
|
|
38
38
|
# @errors Serialization failures are handled by CaptureException.
|
|
39
|
-
# @performance Linear in payload size with bounded depth and collection sizes.
|
|
39
|
+
# @performance Linear in payload size with bounded depth, nodes, and collection sizes.
|
|
40
40
|
class PayloadSerializer
|
|
41
|
-
|
|
42
|
-
MAX_KEYS = 100
|
|
43
|
-
MAX_ITEMS = 100
|
|
44
|
-
MAX_STRING_BYTES = 8192
|
|
45
|
-
|
|
46
|
-
def initialize(config, clock = nil)
|
|
41
|
+
def initialize(config, clock = nil, options = {})
|
|
47
42
|
@config = config
|
|
48
43
|
@clock = clock || proc { Time.now }
|
|
44
|
+
@sanitizer = options[:sanitizer] || Sanitizer.new(config)
|
|
45
|
+
@safe_serializer = options[:safe_serializer] || SafeSerializer.new
|
|
46
|
+
@max_payload_size = options[:max_payload_size] || proc { @config.max_payload_size }
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
def call(notice)
|
|
52
|
-
envelope = build_envelope(notice)
|
|
50
|
+
envelope = @safe_serializer.call(@sanitizer.call(build_envelope(notice)))
|
|
53
51
|
body = JSON.generate(envelope)
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
limit = @max_payload_size.call
|
|
53
|
+
body = JSON.generate(compact_envelope(envelope)) if body.bytesize > limit
|
|
54
|
+
raise Error, "event exceeds max_payload_size" if body.bytesize > limit
|
|
56
55
|
|
|
57
56
|
SerializedEvent.new(notice.event_id, body)
|
|
58
57
|
end
|
|
@@ -66,44 +65,44 @@ module Chronos
|
|
|
66
65
|
"event_type" => "exception",
|
|
67
66
|
"occurred_at" => notice.timestamp,
|
|
68
67
|
"sent_at" => @clock.call.utc.iso8601(6),
|
|
69
|
-
"project_key" =>
|
|
70
|
-
"environment" =>
|
|
68
|
+
"project_key" => @config.project_id,
|
|
69
|
+
"environment" => notice.environment,
|
|
71
70
|
"service" => {
|
|
72
|
-
"name" =>
|
|
73
|
-
"version" =>
|
|
74
|
-
"instance_id" =>
|
|
71
|
+
"name" => @config.service_name,
|
|
72
|
+
"version" => @config.app_version,
|
|
73
|
+
"instance_id" => notice.host
|
|
75
74
|
},
|
|
76
|
-
"runtime" =>
|
|
77
|
-
"context" =>
|
|
75
|
+
"runtime" => notice.runtime,
|
|
76
|
+
"context" => notice.context,
|
|
78
77
|
"payload" => payload(notice)
|
|
79
78
|
}
|
|
80
79
|
end
|
|
81
80
|
|
|
82
81
|
def payload(notice)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
{
|
|
83
|
+
"exception" => {
|
|
84
|
+
"class" => notice.exception_class,
|
|
85
|
+
"message" => notice.message,
|
|
86
|
+
"backtrace" => notice.backtrace,
|
|
87
|
+
"causes" => notice.causes
|
|
88
|
+
},
|
|
89
|
+
"severity" => notice.severity,
|
|
90
|
+
"parameters" => notice.parameters,
|
|
91
|
+
"session" => notice.session,
|
|
92
|
+
"user" => notice.user,
|
|
93
|
+
"versions" => notice.versions,
|
|
94
|
+
"host" => notice.host,
|
|
95
|
+
"process" => notice.process,
|
|
96
|
+
"thread" => notice.thread,
|
|
97
|
+
"tags" => notice.tags,
|
|
98
|
+
"fingerprint" => notice.fingerprint
|
|
99
|
+
}
|
|
101
100
|
end
|
|
102
101
|
|
|
103
102
|
def compact_envelope(envelope)
|
|
104
103
|
payload = envelope["payload"]
|
|
105
104
|
exception = payload["exception"]
|
|
106
|
-
exception["message"] =
|
|
105
|
+
exception["message"] = @safe_serializer.call(exception["message"], :max_string_bytes => 1024)
|
|
107
106
|
exception["backtrace"] = Array(exception["backtrace"]).first(20)
|
|
108
107
|
payload["parameters"] = {"_truncated" => true}
|
|
109
108
|
payload["session"] = {"_truncated" => true}
|
|
@@ -111,55 +110,6 @@ module Chronos
|
|
|
111
110
|
envelope["context"] = {"_truncated" => true}
|
|
112
111
|
envelope
|
|
113
112
|
end
|
|
114
|
-
|
|
115
|
-
def normalize(value, depth)
|
|
116
|
-
return "<maximum depth reached>" if depth >= MAX_DEPTH
|
|
117
|
-
|
|
118
|
-
case value
|
|
119
|
-
when nil, true, false, Integer
|
|
120
|
-
value
|
|
121
|
-
when Float
|
|
122
|
-
value.finite? ? value : value.to_s
|
|
123
|
-
when String, Symbol
|
|
124
|
-
truncate_string(safe_string(value), MAX_STRING_BYTES)
|
|
125
|
-
when Array
|
|
126
|
-
value.first(MAX_ITEMS).map { |child| normalize(child, depth + 1) }
|
|
127
|
-
when Hash
|
|
128
|
-
normalize_hash(value, depth)
|
|
129
|
-
else
|
|
130
|
-
"<#{safe_class_name(value)}>"
|
|
131
|
-
end
|
|
132
|
-
rescue StandardError
|
|
133
|
-
"<unserializable value>"
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def normalize_hash(value, depth)
|
|
137
|
-
result = {}
|
|
138
|
-
value.to_a.first(MAX_KEYS).each do |key, child|
|
|
139
|
-
result[truncate_string(safe_string(key), 256)] = normalize(child, depth + 1)
|
|
140
|
-
end
|
|
141
|
-
result
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def safe_string(value)
|
|
145
|
-
return nil if value.nil?
|
|
146
|
-
|
|
147
|
-
value.to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
|
|
148
|
-
rescue StandardError
|
|
149
|
-
"<unreadable value>"
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def truncate_string(value, limit)
|
|
153
|
-
return value if value.nil? || value.bytesize <= limit
|
|
154
|
-
|
|
155
|
-
value.byteslice(0, limit).to_s.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�") + "…"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def safe_class_name(value)
|
|
159
|
-
value.class.name.to_s
|
|
160
|
-
rescue StandardError
|
|
161
|
-
"Object"
|
|
162
|
-
end
|
|
163
113
|
end
|
|
164
114
|
end
|
|
165
115
|
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Core
|
|
3
|
+
# Converts bounded Ruby structures to values accepted by JSON.generate.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Normalize only JSON primitives while enforcing structural budgets.
|
|
6
|
+
# @motivation Prevent application objects, cycles, or invalid encoding from breaking capture.
|
|
7
|
+
# @limits It does not redact secrets; Sanitizer must run before this component.
|
|
8
|
+
# @collaborators PayloadSerializer.
|
|
9
|
+
# @thread_safety Each call owns its traversal state and can run concurrently.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# serializer.call(:status => :ok) #=> {"status"=>"ok"}
|
|
13
|
+
# @errors Individual unreadable values become bounded placeholders.
|
|
14
|
+
# @performance Depth, nodes, keys, items, and string bytes are bounded.
|
|
15
|
+
class SafeSerializer
|
|
16
|
+
DEFAULTS = {
|
|
17
|
+
:max_depth => 10,
|
|
18
|
+
:max_keys => 100,
|
|
19
|
+
:max_items => 100,
|
|
20
|
+
:max_string_bytes => 8192,
|
|
21
|
+
:max_nodes => 2_000
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(options = {})
|
|
25
|
+
@options = DEFAULTS.merge(options).freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(value, overrides = {})
|
|
29
|
+
settings = @options.merge(overrides)
|
|
30
|
+
normalize(value, 0, {}, {:nodes => 0}, settings)
|
|
31
|
+
rescue StandardError
|
|
32
|
+
"<unserializable value>"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize(value, depth, seen, state, settings)
|
|
38
|
+
state[:nodes] += 1
|
|
39
|
+
return "<node limit reached>" if state[:nodes] > settings[:max_nodes]
|
|
40
|
+
return "<maximum depth reached>" if depth >= settings[:max_depth]
|
|
41
|
+
|
|
42
|
+
return normalize_array(value, depth, seen, state, settings) if value.is_a?(Array)
|
|
43
|
+
return normalize_hash(value, depth, seen, state, settings) if value.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
normalize_scalar(value, settings)
|
|
46
|
+
rescue StandardError
|
|
47
|
+
"<unserializable value>"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def normalize_scalar(value, settings)
|
|
51
|
+
case value
|
|
52
|
+
when nil, true, false, Integer
|
|
53
|
+
value
|
|
54
|
+
when Float
|
|
55
|
+
value.finite? ? value : value.to_s
|
|
56
|
+
when String
|
|
57
|
+
truncate_string(safe_string(value), settings[:max_string_bytes])
|
|
58
|
+
when Symbol
|
|
59
|
+
truncate_string(value.to_s, settings[:max_string_bytes])
|
|
60
|
+
else
|
|
61
|
+
"<#{safe_class_name(value)}>"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def normalize_array(value, depth, seen, state, settings)
|
|
66
|
+
return "<circular reference>" if seen[value.object_id]
|
|
67
|
+
|
|
68
|
+
seen[value.object_id] = true
|
|
69
|
+
result = value.first(settings[:max_items]).map do |child|
|
|
70
|
+
normalize(child, depth + 1, seen, state, settings)
|
|
71
|
+
end
|
|
72
|
+
seen.delete(value.object_id)
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def normalize_hash(value, depth, seen, state, settings)
|
|
77
|
+
return "<circular reference>" if seen[value.object_id]
|
|
78
|
+
|
|
79
|
+
seen[value.object_id] = true
|
|
80
|
+
result = {}
|
|
81
|
+
value.to_a.first(settings[:max_keys]).each do |key, child|
|
|
82
|
+
result[safe_key(key)] = normalize(child, depth + 1, seen, state, settings)
|
|
83
|
+
end
|
|
84
|
+
seen.delete(value.object_id)
|
|
85
|
+
result
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def safe_key(value)
|
|
89
|
+
string = if value.is_a?(String) || value.is_a?(Symbol) || value.is_a?(Numeric)
|
|
90
|
+
value.to_s
|
|
91
|
+
else
|
|
92
|
+
"<#{safe_class_name(value)}>"
|
|
93
|
+
end
|
|
94
|
+
truncate_string(safe_string(string), 256)
|
|
95
|
+
rescue StandardError
|
|
96
|
+
"<unreadable key>"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def safe_string(value)
|
|
100
|
+
value.encode("UTF-8", :invalid => :replace, :undef => :replace, :replace => "�")
|
|
101
|
+
rescue StandardError
|
|
102
|
+
"<unreadable value>"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def truncate_string(value, limit)
|
|
106
|
+
return value if value.bytesize <= limit
|
|
107
|
+
|
|
108
|
+
prefix = value.byteslice(0, limit).to_s
|
|
109
|
+
safe_string(prefix) + "…"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def safe_class_name(value)
|
|
113
|
+
value.class.name.to_s
|
|
114
|
+
rescue StandardError
|
|
115
|
+
"Object"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|