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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +51 -21
  4. data/docs/adr/ADR-005-sanitize-before-backlog.md +2 -2
  5. data/docs/adr/ADR-011-bounded-resilience-and-remote-control.md +27 -0
  6. data/docs/architecture.md +5 -4
  7. data/docs/compatibility.md +6 -6
  8. data/docs/configuration.md +27 -1
  9. data/docs/data-collected.md +10 -7
  10. data/docs/examples/plain-ruby.md +14 -0
  11. data/docs/modules/async-queue.md +6 -2
  12. data/docs/modules/notice-pipeline.md +6 -3
  13. data/docs/modules/remote-configuration.md +54 -0
  14. data/docs/modules/retry-backlog.md +60 -0
  15. data/docs/modules/sanitization.md +19 -0
  16. data/docs/modules/serialization.md +9 -0
  17. data/docs/modules/transport.md +5 -3
  18. data/docs/performance.md +39 -2
  19. data/docs/privacy-lgpd.md +80 -10
  20. data/docs/troubleshooting.md +2 -2
  21. data/lib/chronos/adapters/net_http_transport.rb +21 -3
  22. data/lib/chronos/agent.rb +16 -8
  23. data/lib/chronos/application/capture_exception.rb +20 -14
  24. data/lib/chronos/application/circuit_breaker.rb +70 -0
  25. data/lib/chronos/application/delivery_pipeline.rb +248 -0
  26. data/lib/chronos/application/remote_configuration.rb +173 -0
  27. data/lib/chronos/application/retry_policy.rb +57 -0
  28. data/lib/chronos/configuration.rb +178 -21
  29. data/lib/chronos/core/notice.rb +1 -1
  30. data/lib/chronos/core/payload_serializer.rb +39 -89
  31. data/lib/chronos/core/runtime_info.rb +1 -1
  32. data/lib/chronos/core/safe_serializer.rb +119 -0
  33. data/lib/chronos/core/sanitizer.rb +161 -0
  34. data/lib/chronos/core/sensitive_value_filter.rb +118 -0
  35. data/lib/chronos/internal/bounded_queue.rb +1 -1
  36. data/lib/chronos/internal/memory_backlog.rb +65 -0
  37. data/lib/chronos/internal/worker_pool.rb +5 -6
  38. data/lib/chronos/ports/transport.rb +4 -3
  39. data/lib/chronos/version.rb +1 -1
  40. data/lib/chronos.rb +9 -1
  41. 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 snapshot
56
- errors = validation_errors
57
- raise ConfigurationError, errors.join(", ") unless errors.empty?
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
- Snapshot.new(to_hash)
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 valid?
63
- validation_errors.empty?
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 validation_errors
133
+ def retry_errors
67
134
  errors = []
68
- if enabled
69
- errors << "project_id is required" if blank?(project_id)
70
- errors << "project_key is required" if blank?(project_key)
71
- errors.concat(host_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"
72
141
  end
73
- errors << "timeout must be greater than zero" unless positive_number?(timeout)
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
- private
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
- value = value.dup if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String)
87
- values[attribute] = value
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
- value.freeze if value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(String)
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
@@ -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 Normalize values to JSON primitives and enforce size limits.
30
- # @motivation Prevent arbitrary application objects or invalid encodings from breaking capture.
31
- # @limits Version 0.1 applies structural limits, not the advanced privacy rules from 0.2.
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
- MAX_DEPTH = 10
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
- body = JSON.generate(compact_envelope(envelope)) if body.bytesize > @config.max_payload_size
55
- raise Error, "event exceeds max_payload_size" if body.bytesize > @config.max_payload_size
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" => safe_string(@config.project_id),
70
- "environment" => safe_string(notice.environment),
68
+ "project_key" => @config.project_id,
69
+ "environment" => notice.environment,
71
70
  "service" => {
72
- "name" => safe_string(@config.service_name),
73
- "version" => safe_string(@config.app_version),
74
- "instance_id" => safe_string(notice.host)
71
+ "name" => @config.service_name,
72
+ "version" => @config.app_version,
73
+ "instance_id" => notice.host
75
74
  },
76
- "runtime" => normalize(notice.runtime, 0),
77
- "context" => normalize(notice.context, 0),
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
- normalize({
84
- "exception" => {
85
- "class" => notice.exception_class,
86
- "message" => notice.message,
87
- "backtrace" => notice.backtrace,
88
- "causes" => notice.causes
89
- },
90
- "severity" => notice.severity,
91
- "parameters" => notice.parameters,
92
- "session" => notice.session,
93
- "user" => notice.user,
94
- "versions" => notice.versions,
95
- "host" => notice.host,
96
- "process" => notice.process,
97
- "thread" => notice.thread,
98
- "tags" => notice.tags,
99
- "fingerprint" => notice.fingerprint
100
- }, 0)
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"] = truncate_string(exception["message"], 1024)
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
@@ -22,7 +22,7 @@ module Chronos
22
22
  },
23
23
  :host => safe_hostname,
24
24
  :process => {"pid" => Process.pid},
25
- :thread => {"id" => Thread.current.object_id.to_s}
25
+ :thread => {"id" => "0x#{Thread.current.object_id.to_s(16)}"}
26
26
  }
27
27
  end
28
28
 
@@ -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