Module: Bitfab::Serialize
- Defined in:
- lib/bitfab/serialize.rb
Constant Summary collapse
- MAX_SERIALIZED_BYTES =
Cap on a single serialized value. Walking arbitrary objects can produce hundreds of KB to MB of useless internal state, so this is the cheap early-out that stops the walk before a pathological object is carried any further. It is deliberately the same number as the whole-span budget: one legitimately large value may use the entire budget, and PayloadBudget is what enforces the total once every field is in.
PayloadBudget::MAX_SPAN_CARRIER_BYTES
- MAX_SERIALIZE_DEPTH =
Recursion guard for cyclic graphs and pathologically nested structures.
16
Class Method Summary collapse
- .class_name(value) ⇒ Object
-
.deserialize_inputs(item) ⇒ Array(Array, Hash)
Deserialize replay inputs from a span's data into [args, kwargs].
-
.deserialize_output(raw_output, meta) ⇒ Object
Deserialize a recorded span output.
-
.encode_unbounded(payload) ⇒ Object
Returns [body, encoded_value] where encoded_value is what the body was generated from: the payload itself, or its sanitized copy.
-
.marshal_value(value) ⇒ String?
Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction.
- .oversized?(value) ⇒ Boolean
-
.safe_generate(payload) ⇒ Object
JSON-encode a request or carrier body without ever raising on a stray value.
- .safe_to_s(value) ⇒ Object
-
.safely_call(value, method, *args) ⇒ Object
Call
value.<method>and return the result, or yield to the fallback block if the call raises. -
.sanitize_payload(payload) ⇒ Object
Serialize each top-level value so a bad or oversize value is stubbed in place while the payload keeps its object shape.
-
.serialize_inputs(args, kwargs = {}) ⇒ Object
Serialize function inputs (args + kwargs) for span data (human-readable).
-
.serialize_inputs_with_report(args, kwargs = {}) ⇒ Object
Like serialize_inputs, but also returns the accumulated dropped list so the send boundary can mark a lossy capture non-replayable.
-
.serialize_value(value) ⇒ Object
Serialize a value for JSON storage (human-readable).
- .serialize_value_inner(value, depth, dropped = []) ⇒ Object
-
.serialize_value_with_report(value) ⇒ Object
Like serialize_value, but also reports what could not be faithfully captured.
-
.unmarshal_value(encoded) ⇒ Object
Unmarshal a Base64-encoded string back into a Ruby object.
- .unserializable_stub(value, reason) ⇒ Object
Class Method Details
.class_name(value) ⇒ Object
131 132 133 134 135 |
# File 'lib/bitfab/serialize.rb', line 131 def class_name(value) value.class.name || "Object" rescue "Object" end |
.deserialize_inputs(item) ⇒ Array(Array, Hash)
Deserialize replay inputs from a span's data into [args, kwargs].
Prefers Marshal-serialized inputSerialized for type preservation,
falls back to the raw input field.
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 |
# File 'lib/bitfab/serialize.rb', line 227 def deserialize_inputs(item) input_serialized = item["inputSerialized"] raw_input = item["input"] if input_serialized.is_a?(String) && !input_serialized.empty? begin deserialized = unmarshal_value(input_serialized) if deserialized.is_a?(Hash) && (deserialized.key?(:args) || deserialized.key?(:kwargs)) return [deserialized[:args] || [], deserialized[:kwargs] || {}] end return deserialized.nil? ? [[], {}] : [[deserialized], {}] rescue # Fall through to raw_input end end if raw_input.is_a?(Array) [raw_input, {}] elsif raw_input.is_a?(Hash) [[], raw_input.transform_keys(&:to_sym)] elsif raw_input.nil? [[], {}] else [[raw_input], {}] end end |
.deserialize_output(raw_output, meta) ⇒ Object
Deserialize a recorded span output. Prefers the Ruby-side Marshal+Base64
meta when present (type-preserving); falls back to the raw JSON output
when meta is absent or a shape Ruby cannot reconstruct (e.g. the
superjson/jsonpickle meta another SDK's span may carry). Shared by the
inline mock-tree path and the lazy per-span fetch so both deserialize
identically.
209 210 211 212 213 214 215 216 217 218 |
# File 'lib/bitfab/serialize.rb', line 209 def deserialize_output(raw_output, ) if .is_a?(String) && !.empty? begin return unmarshal_value() rescue # Fall through to the raw JSON output end end raw_output end |
.encode_unbounded(payload) ⇒ Object
Returns [body, encoded_value] where encoded_value is what the body was generated from: the payload itself, or its sanitized copy.
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'lib/bitfab/serialize.rb', line 284 def encode_unbounded(payload) [JSON.generate(payload), payload] rescue => e Bitfab.warn_once( "request-body-stubbed", "a request body held a non-serializable value (#{e.}); it was " \ "stubbed so the span still sends, but the trace may be incomplete or " \ "not replayable. Capture a JSON-safe projection of this input to make " \ "it replayable." ) begin sanitized = sanitize_payload(payload) [JSON.generate(sanitized), sanitized] rescue # Truly pathological. Still never drop silently: send a marker body. marker = {"error" => "payload_serialize_failed"} [JSON.generate(marker), marker] end end |
.marshal_value(value) ⇒ String?
Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction. Handles arbitrary Ruby objects including custom classes.
181 182 183 184 185 186 187 188 189 |
# File 'lib/bitfab/serialize.rb', line 181 def marshal_value(value) dumped = Marshal.dump(value) return nil if dumped.bytesize > MAX_SERIALIZED_BYTES Base64.strict_encode64(dumped) rescue TypeError, ArgumentError # Some objects (Proc, IO, etc.) can't be marshalled nil end |
.oversized?(value) ⇒ Boolean
141 142 143 144 145 146 147 |
# File 'lib/bitfab/serialize.rb', line 141 def oversized?(value) JSON.dump(value).bytesize > MAX_SERIALIZED_BYTES rescue # If JSON.dump can't handle it, the wire path can't either, so treat as # oversized to force the stub fallback. true end |
.safe_generate(payload) ⇒ Object
JSON-encode a request or carrier body without ever raising on a stray value.
Upstream serialization (serialize_value) should already have flattened user data. This is the boundary backstop: if anything non-serializable still slips through, it is run through serialize_value (which never raises and stubs strays) instead of letting JSON.generate raise and drop the whole span/trace silently. A degraded payload warns loudly so the trace isn't quietly left incomplete or not replayable.
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/bitfab/serialize.rb', line 263 def safe_generate(payload) # Budget the value that was actually encoded, not the caller's original: a # cyclic or otherwise non-encodable field cannot be sized (JSON.generate # raises on it), so on the original graph the biggest field is skipped as # a trim candidate and the oversized body ships anyway. The sanitized copy # has those values already replaced with stubs, so every field is sizeable. body, encoded = encode_unbounded(payload) body, trimmed = PayloadBudget.enforce(encoded, body) { |value| encode_unbounded(value).first } if trimmed.any? Bitfab.warn_once( "payload-over-budget", "a span payload exceeded the #{PayloadBudget::MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its " \ "largest field(s) (#{trimmed.uniq.join(", ")}) were replaced with placeholders " \ "so the span still ships. The span is incomplete and may not be replayable." ) end body end |
.safe_to_s(value) ⇒ Object
124 125 126 127 128 129 |
# File 'lib/bitfab/serialize.rb', line 124 def safe_to_s(value) str = value.to_s str.is_a?(String) ? str : "<#{class_name(value)}: to_s returned non-String>" rescue "<#{class_name(value)}: to_s raised>" end |
.safely_call(value, method, *args) ⇒ Object
Call value.<method> and return the result, or yield to the fallback
block if the call raises. Used to harden every call site that invokes a
user-defined method on an arbitrary object.
118 119 120 121 122 |
# File 'lib/bitfab/serialize.rb', line 118 def safely_call(value, method, *args) value.public_send(method, *args) rescue yield end |
.sanitize_payload(payload) ⇒ Object
Serialize each top-level value so a bad or oversize value is stubbed in place while the payload keeps its object shape. Running serialize_value on the whole payload could collapse the entire body to a single stub string (oversize/cyclic), sending a JSON string instead of a span object.
309 310 311 312 313 314 315 |
# File 'lib/bitfab/serialize.rb', line 309 def sanitize_payload(payload) return {"error" => "payload_serialize_failed"} unless payload.is_a?(Hash) payload.each_with_object({}) do |(k, v), acc| acc[k.to_s] = serialize_value(v) end end |
.serialize_inputs(args, kwargs = {}) ⇒ Object
Serialize function inputs (args + kwargs) for span data (human-readable).
150 151 152 |
# File 'lib/bitfab/serialize.rb', line 150 def serialize_inputs(args, kwargs = {}) serialize_inputs_with_report(args, kwargs).first end |
.serialize_inputs_with_report(args, kwargs = {}) ⇒ Object
Like serialize_inputs, but also returns the accumulated dropped list so the send boundary can mark a lossy capture non-replayable.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/bitfab/serialize.rb', line 156 def serialize_inputs_with_report(args, kwargs = {}) dropped = [] serialized = args.map do |arg| result, arg_dropped = serialize_value_with_report(arg) dropped.concat(arg_dropped) result end unless kwargs.empty? kw = kwargs.each_with_object({}) do |(k, v), acc| result, v_dropped = serialize_value_with_report(v) dropped.concat(v_dropped) acc[safe_to_s(k)] = result end serialized << kw end [serialized, dropped] end |
.serialize_value(value) ⇒ Object
Serialize a value for JSON storage (human-readable). Handles primitives, hashes, arrays, and objects with common conversion methods. Note: We intentionally avoid as_json here because it requires ActiveSupport, and we want to keep the SDK dependency-free (stdlib only).
Guarantees:
- Never raises. Pathological inputs (objects with raising to_s/to_h, cycles, BasicObject subclasses) return a stub string.
- Never returns a value whose JSON encoding exceeds MAX_SERIALIZED_BYTES. Without this the wire-side JSON.dump in the http client can produce a request that times out or gets rejected, leaving a trace with zero spans.
35 36 37 |
# File 'lib/bitfab/serialize.rb', line 35 def serialize_value(value) serialize_value_with_report(value).first end |
.serialize_value_inner(value, depth, dropped = []) ⇒ Object
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/bitfab/serialize.rb', line 57 def serialize_value_inner(value, depth, dropped = []) if depth > MAX_SERIALIZE_DEPTH dropped << "max_depth" return "<unserializable: max_depth>" end case value when Float # JSON has no NaN/Infinity literal, so Ruby's generator raises on one. # Left unstubbed it kills the encode for the whole batch, not just this # span. Mirrors the Python SDK's non_finite_float placeholder. if value.finite? value else dropped << "non_finite_float" "<unserializable: non_finite_float>" end when nil, true, false, Integer, String value when Hash value.each_with_object({}) do |(k, v), acc| acc[safe_to_s(k)] = serialize_value_inner(v, depth + 1, dropped) end when Array value.map { |v| serialize_value_inner(v, depth + 1, dropped) } when Set value.map { |v| serialize_value_inner(v, depth + 1, dropped) } when Time, DateTime safely_call(value, "iso8601", 3) { safe_to_s(value) } when Date safe_to_s(value) when Symbol value.to_s else if value.respond_to?(:to_h) h = safely_call(value, "to_h") do dropped << class_name(value) return unserializable_stub(value, "to_h_raised") end serialize_value_inner(h, depth + 1, dropped) elsif value.respond_to?(:to_a) a = safely_call(value, "to_a") do dropped << class_name(value) return unserializable_stub(value, "to_a_raised") end serialize_value_inner(a, depth + 1, dropped) else # An arbitrary object with no structured conversion: stringifying it is # a lossy capture (a repr, not its data), so report it as dropped. dropped << class_name(value) safe_to_s(value) end end rescue StandardError, SystemStackError dropped << class_name(value) unserializable_stub(value, "inner_error") end |
.serialize_value_with_report(value) ⇒ Object
Like serialize_value, but also reports what could not be faithfully captured. Returns [result, dropped] where dropped lists the class name (or "max_depth"/"too_large") behind every placeholder the walk had to emit. A non-empty dropped means the capture is lossy, so the send boundary marks the span serialization_degraded (non-replayable) instead of shipping it as if it round-trips. Mirrors the Python/TS SDKs' report serializers.
45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/bitfab/serialize.rb', line 45 def serialize_value_with_report(value) dropped = [] result = serialize_value_inner(value, 0, dropped) if oversized?(result) dropped << "too_large" return [unserializable_stub(value, "too_large"), dropped] end [result, dropped] rescue StandardError, SystemStackError [unserializable_stub(value, "unexpected_error"), [class_name(value)]] end |
.unmarshal_value(encoded) ⇒ Object
Unmarshal a Base64-encoded string back into a Ruby object.
195 196 197 |
# File 'lib/bitfab/serialize.rb', line 195 def unmarshal_value(encoded) Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad end |
.unserializable_stub(value, reason) ⇒ Object
137 138 139 |
# File 'lib/bitfab/serialize.rb', line 137 def unserializable_stub(value, reason) "<unserializable: #{class_name(value)} (#{reason})>" end |