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 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.

Parameters:

  • item (Hash)

    with "inputSerialized" and/or "input" keys

Returns:

  • (Array(Array, Hash))

    positional args and keyword args



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.

Parameters:

  • raw_output (Object)

    the human-readable JSON output

  • meta (String, nil)

    the Marshal+Base64 metadata, if any

Returns:

  • (Object)

    the reconstructed output, or raw_output on fallback



209
210
211
212
213
214
215
216
217
218
# File 'lib/bitfab/serialize.rb', line 209

def deserialize_output(raw_output, meta)
  if meta.is_a?(String) && !meta.empty?
    begin
      return unmarshal_value(meta)
    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.message}); 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.

Parameters:

  • value (Object)

    any Ruby value

Returns:

  • (String, nil)

    Base64-encoded Marshal dump, or nil if marshalling fails (objects with non-marshallable members like Proc/IO, or whose marshalled size exceeds MAX_SERIALIZED_BYTES).



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

Returns:

  • (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.

Parameters:

  • encoded (String)

    Base64-encoded Marshal dump

Returns:

  • (Object)

    the reconstructed 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