class Safe
A safe format for converting objects to strings.
Handles issues like circular references, encoding errors, excessive nesting depth, and excessive output size.
Definitions
TRUNCATED = "\"truncated\":true"
The JSON fragment used as the truncation marker when dropped fields cannot be named.
def initialize(format: ::JSON, depth_limit: 12, size_limit: 16 * 1024, encoding: ::Encoding::UTF_8, limit: nil)
Create a new safe format.
Signature
-
parameter
formatJSON The format to use for serialization.
-
parameter
depth_limitInteger The maximum depth to recurse into objects (the JSON
max_nesting).-
parameter
size_limitInteger | Nil The maximum byte size of the serialized output, or
nilto disable size limiting. Limits belowTRUNCATED = "\"truncated\":true"(the minimal marker) cannot be honoured.-
parameter
encodingEncoding The encoding to use for strings.
-
parameter
limitInteger | Nil Deprecated alias for
depth_limit.
Implementation
def initialize(format: ::JSON, depth_limit: 12, size_limit: 16 * 1024, encoding: ::Encoding::UTF_8, limit: nil)
if limit
warn "Console::Format::Safe `limit:` is deprecated, use `depth_limit:` instead.", uplevel: 1, category: :deprecated
depth_limit = limit
end
@format = format
@depth_limit = depth_limit
@size_limit = size_limit
@encoding = encoding
end
attr :depth_limit
Signature
-
attribute
Integer The maximum depth to recurse into objects.
attr :size_limit
Signature
-
attribute
Integer | Nil The maximum byte size of the serialized output.
def dump(object)
Dump the given object to a string.
The common case is a single fast serialization. If that fails (e.g. circular
references, excessive nesting, or encoding errors) or its output exceeds
attr :size_limit, it falls back to Console::Format::Safe#safe_dump, which rebuilds the record
field-by-field within the limit.
Signature
-
parameter
objectObject The object to dump.
-
returns
String The dumped object.
Implementation
def dump(object)
buffer = @format.dump(object, @depth_limit)
if @size_limit and buffer.bytesize > @size_limit
return safe_dump(object)
end
return buffer
rescue SystemStackError, StandardError
return safe_dump(object)
end
def safe_dump(object)
Produce a safe, size-limited serialization of the given object. This is the
fallback path, used both when direct serialization fails (an exception) and
when its output exceeds attr :size_limit.
Each top-level value is serialized independently and defensively, so a single
un-serializable or oversized value cannot break or bloat the whole record.
Whenever a field is degraded, the reason is recorded in a trailing "truncated"
object that maps the field name to why it was truncated:
"key": true— the value was dropped because it did not fit the size limit."key": {error}— the value could not be serialized directly; a safe representation was kept in its place and the triggering error is recorded.
Fields are kept while they fit, always reserving room for at least a minimal
"truncated":true marker. The detailed reason map is then emitted only if it
fits in the remaining space; otherwise it degrades to "truncated":true. This
is best-effort — in the worst case the per-field detail is lost — but it keeps
the bookkeeping simple and the size guarantee hard.
Signature
-
parameter
objectObject The object to serialize.
-
returns
String The safe, size-limited serialized record.
Implementation
def safe_dump(object)
# Serialize hash-like objects field-by-field; anything else falls through to the
# error handler below, which emits a minimal truncated marker.
object = object.to_hash
# Serialize each field once, capturing the error for any value that could not be
# serialized directly. Our own "truncated" key is skipped so it is never duplicated.
errors = {}
fragments = []
object.each do |key, value|
name = key.to_s
next if name == "truncated"
fragment, error = dump_pair(key, value)
errors[name] = error_info(error) if error
fragments << [name, fragment]
end
# Assemble the body, keeping each field while it fits — always reserving room for
# at least a minimal `"truncated":true` marker. Each truncated field's reason is
# collected: its error (value recovered) or `true` (dropped for size).
buffer = +"{"
first = true
reasons = {}
fragments.each do |name, fragment|
if buffer.bytesize + (first ? 0 : 1) + fragment.bytesize + TRUNCATED.bytesize + 2 <= @size_limit
buffer << "," unless first
buffer << fragment
first = false
# The value was kept; if it had to be recovered, note why.
reasons[name] = errors[name] if errors[name]
else
# The value did not fit and was dropped entirely.
reasons[name] = true
end
end
unless reasons.empty?
# Include the detailed reasons if they fit, otherwise fall back to the minimal
# marker so the truncation is still signalled.
detailed = "\"truncated\":#{@format.dump(reasons)}"
fits = buffer.bytesize + (first ? 0 : 1) + detailed.bytesize + 1 <= @size_limit
buffer << "," unless first
buffer << (fits ? detailed : TRUNCATED)
end
buffer << "}"
return buffer
rescue SystemStackError, StandardError
return "{#{TRUNCATED}}"
end
def dump_pair(key, value)
Serialize a single top-level "key":value pair, safely handling values that
cannot be serialized directly.
Signature
-
parameter
keyObject The field key.
-
parameter
valueObject The field value.
-
returns
Array(String, Exception | Nil) The
"key":valuefragment and the error, if recovery was needed.
Implementation
def dump_pair(key, value)
value_json, error = dump_value(value)
return ["#{dump_string(String(key))}:#{value_json}", error]
end
def dump_value(value)
Serialize a single value, falling back to a safe representation on failure.
Signature
-
parameter
valueObject The value to serialize.
-
returns
Array(String, Exception | Nil) The serialized value and the error, if recovery was needed.
Implementation
def dump_value(value)
[@format.dump(value, @depth_limit), nil]
rescue SystemStackError, StandardError => error
[@format.dump(safe_dump_recurse(value)), error]
end
def dump_string(value)
Serialize a string as a JSON string, encoding it safely first.
Signature
-
parameter
valueString The string to serialize.
-
returns
String The serialized (quoted) string.
Implementation
def dump_string(value)
@format.dump(value.encode(@encoding, invalid: :replace, undef: :replace))
end
def filter_backtrace(error)
Filter the backtrace to remove duplicate frames and reduce verbosity.
Signature
-
parameter
errorException The exception to filter.
-
returns
Array(String) The filtered backtrace.
Implementation
def filter_backtrace(error)
frames = error.backtrace
filtered = {}
filtered_count = nil
skipped = nil
frames = frames.filter_map do |frame|
if filtered[frame]
if filtered_count == nil
filtered_count = 1
skipped = frame.dup
else
filtered_count += 1
nil
end
else
if skipped
if filtered_count > 1
skipped.replace("[... #{filtered_count} frames skipped ...]")
end
filtered_count = nil
skipped = nil
end
filtered[frame] = true
frame
end
end
if skipped && filtered_count > 1
skipped.replace("[... #{filtered_count} frames skipped ...]")
end
return frames
end
def error_info(error)
Build a safe, primitive representation of an error for inclusion as an "error" field.
Signature
-
parameter
errorException The error that occurred while dumping the object.
-
returns
Hash The error details (class, message, filtered backtrace).
Implementation
def error_info(error)
{
class: safe_dump_recurse(error.class.name),
message: safe_dump_recurse(error.message),
backtrace: safe_dump_recurse(filter_backtrace(error)),
}
end
def default_objects
Create a new hash with identity comparison.
Implementation
def default_objects
Hash.new.compare_by_identity
end
def safe_dump_recurse(object, limit = @depth_limit, objects = default_objects)
This will recursively generate a safe version of the object. Nested hashes and arrays will be transformed recursively. Strings will be encoded with the given encoding. Primitive values will be returned as-is. Other values will be converted using as_json if available, otherwise to_s.
Signature
-
parameter
objectObject The object to dump.
-
parameter
limitInteger The maximum depth to recurse into objects.
-
parameter
objectsHash The objects that have already been visited.
-
returns
Object The dumped object as a primitive representation.
Implementation
def safe_dump_recurse(object, limit = @depth_limit, objects = default_objects)
case object
when Hash
if limit <= 0 || objects[object]
return "{...}"
else
objects[object] = true
return object.to_h do |key, value|
[
String(key).encode(@encoding, invalid: :replace, undef: :replace),
safe_dump_recurse(value, limit - 1, objects)
]
end
end
when Array
if limit <= 0 || objects[object]
return "[...]"
else
objects[object] = true
return object.map do |value|
safe_dump_recurse(value, limit - 1, objects)
end
end
when String
return object.encode(@encoding, invalid: :replace, undef: :replace)
when Numeric, TrueClass, FalseClass, NilClass
return object
else
if limit <= 0 || objects[object]
return "..."
else
objects[object] = true
# We could do something like this but the chance `as_json` will blow up.
# We'd need to be extremely careful about it.
# if object.respond_to?(:as_json)
# safe_dump_recurse(object.as_json, limit - 1, objects)
# else
return safe_dump_recurse(object.to_s, limit - 1, objects)
end
end
end