Module: Bitfab::Serialize

Defined in:
lib/bitfab/serialize.rb

Constant Summary collapse

MAX_SERIALIZED_BYTES =

Cap on serialized payload size. Walking arbitrary objects (e.g. an OpenAI client passed as a span input) can produce hundreds of KB to MB of useless internal state. Anything beyond this cap is replaced with a stub so the span still ships and the trace isn’t dropped server-side.

512_000
MAX_SERIALIZE_DEPTH =

Recursion guard for cyclic graphs and pathologically nested structures.

16

Class Method Summary collapse

Class Method Details

.class_name(value) ⇒ Object



117
118
119
120
121
# File 'lib/bitfab/serialize.rb', line 117

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



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/bitfab/serialize.rb', line 192

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

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



167
168
169
170
171
172
173
174
175
# File 'lib/bitfab/serialize.rb', line 167

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)


127
128
129
130
131
132
133
# File 'lib/bitfab/serialize.rb', line 127

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_to_s(value) ⇒ Object



110
111
112
113
114
115
# File 'lib/bitfab/serialize.rb', line 110

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.



104
105
106
107
108
# File 'lib/bitfab/serialize.rb', line 104

def safely_call(value, method, *args)
  value.public_send(method, *args)
rescue
  yield
end

.serialize_inputs(args, kwargs = {}) ⇒ Object

Serialize function inputs (args + kwargs) for span data (human-readable).



136
137
138
# File 'lib/bitfab/serialize.rb', line 136

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.



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/bitfab/serialize.rb', line 142

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.



31
32
33
# File 'lib/bitfab/serialize.rb', line 31

def serialize_value(value)
  serialize_value_with_report(value).first
end

.serialize_value_inner(value, depth, dropped = []) ⇒ Object



53
54
55
56
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
# File 'lib/bitfab/serialize.rb', line 53

def serialize_value_inner(value, depth, dropped = [])
  if depth > MAX_SERIALIZE_DEPTH
    dropped << "max_depth"
    return "<unserializable: max_depth>"
  end

  case value
  when nil, true, false, Integer, Float, 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.



41
42
43
44
45
46
47
48
49
50
51
# File 'lib/bitfab/serialize.rb', line 41

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



181
182
183
# File 'lib/bitfab/serialize.rb', line 181

def unmarshal_value(encoded)
  Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
end

.unserializable_stub(value, reason) ⇒ Object



123
124
125
# File 'lib/bitfab/serialize.rb', line 123

def unserializable_stub(value, reason)
  "<unserializable: #{class_name(value)} (#{reason})>"
end