bitfab 0.74.1 → 0.74.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1def1a5df451e3c7b1afa22248c2c54b2b135f5712eb19a989b41209a8c5622
4
- data.tar.gz: 16a9741bbda86c16c78aafec91688d962c9ae58e9b032681a07d7b633497c258
3
+ metadata.gz: e04db08d34f7d195036216aa6f41ee940f81af773924508c2a12da97fbbd1e4a
4
+ data.tar.gz: 8fd59297c7eeb7e2d053ca2e4722d5b6aea43ae5c96f691a2addf81f0f9452ea
5
5
  SHA512:
6
- metadata.gz: 2be36b8b49c5b7c6d6525364c6f10a24d696eb40d8dae2343bdf1b1017e2f2fd47e6c52d8cdf200b4f1cf22a75fdd0ea41bf217e533090a9a42fa725ee7018a8
7
- data.tar.gz: 5a4fe53c914d211714bbd2a31d44af37743edf1007f4599f52c6dfcce9e3bf970d90778588b1ee17267394017ed554752882f63d45a06fa7ce693786b97dc152
6
+ metadata.gz: aa1099e23b6af4619238c7b6b94f13e9ad15ead7aa6dcaa7fb5a993b634dd00ab6468c5924bcc717da520fa9f0e96d3d5e82a7ab32656e96885626b07e38c3e2
7
+ data.tar.gz: a99c779521142a681697cdadd6b031b1f2f181e851c919ec2f8e571dd3eb447eeccb6063380c5ee8ccfc706e420ef1e2fe23bc0fdd2eb5802e5930838372a075
data/lib/bitfab/client.rb CHANGED
@@ -1021,7 +1021,7 @@ module Bitfab
1021
1021
  "type" => "sdk-function",
1022
1022
  "source" => "ruby-sdk-function",
1023
1023
  "traceFunctionKey" => trace_function_key,
1024
- "externalTrace" => raw_trace,
1024
+ "rawTrace" => raw_trace,
1025
1025
  "completed" => true
1026
1026
  }
1027
1027
 
@@ -1037,7 +1037,7 @@ module Bitfab
1037
1037
  payload["testRunId"] = trace_state[:experiment_id]
1038
1038
  end
1039
1039
 
1040
- @http_client.send_external_trace(payload)
1040
+ @http_client.send_trace_payload(payload)
1041
1041
 
1042
1042
  # Clean up trace state
1043
1043
  TraceState.delete(trace_id)
@@ -1069,8 +1069,9 @@ module Bitfab
1069
1069
  span_data.merge!(extra_span_data.except(*SimulationPlan::CONTENT_KEYS))
1070
1070
  span_data["content_off_by_simulation_plan"] = true
1071
1071
  else
1072
- human_inputs, input_dropped = Serialize.serialize_inputs_with_report(args, kwargs)
1072
+ human_inputs, input_dropped, (kept_args, kept_kwargs) = Serialize.serialize_inputs_with_report(args, kwargs)
1073
1073
  human_output, output_dropped = Serialize.serialize_value_with_report(result)
1074
+ kept_result = Serialize.too_large?(output_dropped) ? human_output : result
1074
1075
  dropped = input_dropped + output_dropped
1075
1076
 
1076
1077
  span_data = {
@@ -1081,10 +1082,10 @@ module Bitfab
1081
1082
  "function_name" => function_name
1082
1083
  }
1083
1084
  span_data.merge!(extra_span_data)
1084
- snapshot = SpanMeta::Snapshot.capture(args, kwargs, result)
1085
+ snapshot = SpanMeta::Snapshot.capture(kept_args, kept_kwargs, kept_result)
1085
1086
  unless snapshot
1086
- input_meta = SpanMeta.build_input_meta(args, kwargs, human_inputs)
1087
- output_meta = SpanMeta.build_output_meta(result, human_output)
1087
+ input_meta = SpanMeta.build_input_meta(kept_args, kept_kwargs, human_inputs)
1088
+ output_meta = SpanMeta.build_output_meta(kept_result, human_output)
1088
1089
  span_data["input_meta"] = input_meta if input_meta
1089
1090
  span_data["output_meta"] = output_meta if output_meta
1090
1091
  end
@@ -1145,7 +1146,7 @@ module Bitfab
1145
1146
  }]
1146
1147
  end
1147
1148
 
1148
- @http_client.send_external_span(payload, declared_node, snapshot)
1149
+ @http_client.send_span_payload(payload, declared_node, snapshot)
1149
1150
  end
1150
1151
 
1151
1152
  # Advance the per-(key, name) call counter when this invocation is a
@@ -1179,10 +1180,6 @@ module Bitfab
1179
1180
  def check_mock_replay(replay_ctx, trace_function_key, span_name, call_index, span_type:, args:, kwargs:, mock_on_replay:)
1180
1181
  mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
1181
1182
 
1182
- # 1) Selective overrides. The matcher runs on structural metadata only;
1183
- # the recorded output is fetched (lazily, by externalSpanId) only if the
1184
- # processor actually asks for it via get_original_output, so a pure
1185
- # synthetic override triggers zero output fetches.
1186
1183
  overrides = replay_ctx[:mock_overrides]
1187
1184
  if overrides&.any?
1188
1185
  node = {
@@ -1242,7 +1239,7 @@ module Bitfab
1242
1239
  # Resolve a mock-tree entry's recorded output. Prefers an inline output when
1243
1240
  # the tree carried one (eager "all", or an older server that ignores
1244
1241
  # includeOutputs and returns outputs); otherwise lazily fetches it by
1245
- # externalSpanId through the per-item memoized fetcher on the replay
1242
+ # span ID through the per-item memoized fetcher on the replay
1246
1243
  # context, so only the spans actually mocked pull their output.
1247
1244
  #
1248
1245
  # Deserialization is type-preserving when Ruby-side Marshal+Base64 metadata
@@ -1257,9 +1254,9 @@ module Bitfab
1257
1254
  end
1258
1255
 
1259
1256
  fetcher = replay_ctx[:fetch_span_output]
1260
- external_span_id = mock_entry[:external_span_id]
1261
- if fetcher && external_span_id
1262
- return fetcher.call(external_span_id)
1257
+ span_row_id = mock_entry[:span_row_id]
1258
+ if fetcher && span_row_id
1259
+ return fetcher.call(span_row_id)
1263
1260
  end
1264
1261
 
1265
1262
  # No inline output and nothing to lazily fetch. This is a span that never
@@ -192,8 +192,8 @@ module Bitfab
192
192
  raise last_error
193
193
  end
194
194
 
195
- # Queue an external span on this client's trace transport (fire-and-forget).
196
- def send_external_span(payload, declared_node = false, snapshot = nil)
195
+ # Queue a span payload on this client's trace transport (fire-and-forget).
196
+ def send_span_payload(payload, declared_node = false, snapshot = nil)
197
197
  if (data = payload.dig("rawSpan", "span_data")).is_a?(Hash) && !data.key?("runtime")
198
198
  payload = payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => data.merge("runtime" => ThreadPropagation.runtime)))
199
199
  end
@@ -334,11 +334,12 @@ module Bitfab
334
334
  request("/api/sdk/replay/start", payload, timeout:)
335
335
  end
336
336
 
337
- # Fetch an external span by ID. Blocking GET request. The replay view keeps
338
- # only the input/output serialization fields used by replay.
339
- def get_external_span(span_id, replay_view: false)
337
+ # Fetch a span's raw record by its Bitfab row ID. Blocking GET request.
338
+ # The replay view keeps only the input/output serialization fields used
339
+ # by replay.
340
+ def get_span_raw(span_id, replay_view: false)
340
341
  query = replay_view ? "?view=replay" : ""
341
- get("/api/sdk/externalSpans/#{span_id}#{query}", timeout: 30)
342
+ get("/api/sdk/spans/#{span_id}/raw#{query}", timeout: 30)
342
343
  end
343
344
 
344
345
  def get_trace_span(trace_id, id: nil, name: nil, type: nil, occurrence: "last")
@@ -359,19 +360,19 @@ module Bitfab
359
360
  get("/api/sdk/spans/#{encoded_span_id}", timeout: 30)["span"]
360
361
  end
361
362
 
362
- # Fetch the span tree rooted at an external span. Blocking GET request.
363
+ # Fetch the span tree rooted at a span. Blocking GET request.
363
364
  # Used by replay when a mock strategy is active so child spans can be
364
365
  # matched against their historical outputs.
365
366
  #
366
367
  # Returns a hash shaped { "root" => SpanTreeNode } where each node has
367
- # sourceSpanId, externalSpanId, traceFunctionKey, spanName, type, and
368
+ # sourceSpanId, spanId, traceFunctionKey, spanName, type, and
368
369
  # children. When include_outputs is true each node also carries its recorded
369
370
  # output (and optional outputMeta); when false the server omits the output
370
371
  # payloads so only the spans actually mocked are fetched later (by
371
- # externalSpanId), avoiding dragging down every span's output when only a
372
+ # spanId), avoiding dragging down every span's output when only a
372
373
  # few are mocked.
373
- def get_span_tree(external_span_id, include_outputs: true, include_root_output: true)
374
- endpoint = "/api/sdk/replay/spanTree/#{external_span_id}"
374
+ def get_span_tree(span_id, include_outputs: true, include_root_output: true)
375
+ endpoint = "/api/sdk/replay/spanTree/#{span_id}"
375
376
  query = []
376
377
  query << "includeOutputs=false" unless include_outputs
377
378
  query << "includeRootOutput=false" unless include_root_output
@@ -402,23 +403,23 @@ module Bitfab
402
403
  )
403
404
  end
404
405
 
405
- # Queue an external trace on this client's trace transport (fire-and-forget).
406
- def send_external_trace(payload)
406
+ # Queue a trace payload on this client's trace transport (fire-and-forget).
407
+ def send_trace_payload(payload)
407
408
  submit = ->(record) {
408
409
  ref = carrier_ref(record)
409
410
  if record["completed"] == true && ref
410
411
  @trace_completion.close(ref.trace_id, dropped: record["dropped"] == true) do |count|
411
- submit_external_trace(record.merge("expectedSpanCount" => count))
412
+ submit_trace_payload(record.merge("expectedSpanCount" => count))
412
413
  end
413
414
  else
414
415
  @trace_completion.open(ref.trace_id) if ref
415
- submit_external_trace(record)
416
+ submit_trace_payload(record)
416
417
  end
417
418
  }
418
419
  @simulation_plan ? @simulation_plan.send_trace(payload, &submit) : submit.call(payload)
419
420
  end
420
421
 
421
- def submit_external_trace(payload)
422
+ def submit_trace_payload(payload)
422
423
  trace_transport&.submit(
423
424
  "external_trace",
424
425
  payload.merge("sdkVersion" => VERSION),
@@ -473,7 +474,7 @@ module Bitfab
473
474
  def payload_timestamp(payload, field)
474
475
  raw = payload.dig("rawSpan", field) if payload["rawSpan"].is_a?(Hash)
475
476
  if raw.nil?
476
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
477
+ raw_trace = payload["rawTrace"] || payload["externalTrace"]
477
478
  raw = raw_trace[field] if raw_trace.is_a?(Hash)
478
479
  end
479
480
  return nil unless raw.is_a?(String)
@@ -496,7 +497,7 @@ module Bitfab
496
497
  def carrier_ref(payload)
497
498
  trace_id = payload["sourceTraceId"]
498
499
  unless trace_id.is_a?(String)
499
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
500
+ raw_trace = payload["rawTrace"] || payload["externalTrace"]
500
501
  trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
501
502
  end
502
503
  return nil unless trace_id.is_a?(String)
data/lib/bitfab/replay.rb CHANGED
@@ -1144,20 +1144,9 @@ module Bitfab
1144
1144
  )
1145
1145
  end
1146
1146
 
1147
- span = http_client.get_external_span(original_span_id, replay_view: true)
1147
+ span = http_client.get_span_raw(original_span_id, replay_view: true)
1148
1148
  item_data = extract_span_data(span)
1149
1149
 
1150
- # Fetch the span tree when the base strategy needs recorded outputs
1151
- # ("all"/"marked") OR when selective mock overrides are present. Overrides
1152
- # gate on the same non-root call-counter machinery the tree drives, so
1153
- # the tree must be fetched for them to fire even under mock: "none".
1154
- #
1155
- # Only mock: "all" needs every span's recorded output inline (it mocks
1156
- # every matched recorded child), so it fetches an eager tree even when
1157
- # overrides exist.
1158
- # Non-"all" runs that need a tree ("marked", or "none" with overrides)
1159
- # fetch it payload-free (includeOutputs=false) and pull each mocked span's
1160
- # output lazily by externalSpanId, never dragging down unused outputs.
1161
1150
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
1162
1151
  include_outputs = mock_strategy == "all"
1163
1152
  mock_tree = nil
@@ -1201,7 +1190,7 @@ module Bitfab
1201
1190
  experiment_id,
1202
1191
  span["id"],
1203
1192
  metrics,
1204
- input_source_trace_id: span["externalTraceId"],
1193
+ input_source_trace_id: span["traceId"] || span["externalTraceId"],
1205
1194
  selective_replay:,
1206
1195
  mock_strategy:,
1207
1196
  mock_tree:,
@@ -1294,14 +1283,9 @@ module Bitfab
1294
1283
  counter_key = "#{key}:#{name}"
1295
1284
  index = counters[counter_key] || 0
1296
1285
  counters[counter_key] = index + 1
1297
- # externalSpanId is what the lazy path fetches the recorded output by
1298
- # when the tree came back payload-free (includeOutputs=false). output
1299
- # / outputMeta are copied ONLY when the node actually carries them, so
1300
- # the presence of the :output key distinguishes an inline nil output
1301
- # from a payload-free entry (mirrors TS's undefined-vs-null check).
1302
1286
  entry = {
1303
1287
  source_span_id: node["sourceSpanId"],
1304
- external_span_id: node["externalSpanId"]
1288
+ span_row_id: node["spanId"] || node["externalSpanId"]
1305
1289
  }
1306
1290
  entry[:output] = node["output"] if node.key?("output")
1307
1291
  entry[:output_meta] = node["outputMeta"] if node.key?("outputMeta")
@@ -1316,25 +1300,25 @@ module Bitfab
1316
1300
  end
1317
1301
 
1318
1302
  # Build a memoized lazy fetcher for a single replay item. The returned
1319
- # lambda takes an externalSpanId and returns that span's deserialized
1320
- # recorded output, fetching it via get_external_span at most once per id
1303
+ # lambda takes a span ID and returns that span's deserialized
1304
+ # recorded output, fetching it via get_span_raw at most once per id
1321
1305
  # (a span read twice - as a base "marked" mock and via an override's
1322
1306
  # get_original_output - fetches once). The cache is per item: each item
1323
1307
  # replays synchronously on its own thread, so no lock is needed.
1324
1308
  def build_span_output_fetcher(http_client)
1325
1309
  cache = {}
1326
- lambda do |external_span_id|
1327
- return cache[external_span_id] if cache.key?(external_span_id)
1310
+ lambda do |span_row_id|
1311
+ return cache[span_row_id] if cache.key?(span_row_id)
1328
1312
 
1329
- span = http_client.get_external_span(external_span_id, replay_view: true)
1313
+ span = http_client.get_span_raw(span_row_id, replay_view: true)
1330
1314
  span_data = (span["rawData"] || {})["span_data"] || {}
1331
1315
  meta = span_data["output_serialized"]
1332
1316
  meta = span_data["output_meta"] if meta.nil? || meta == ""
1333
- cache[external_span_id] = Serialize.deserialize_output(span_data["output"], meta)
1317
+ cache[span_row_id] = Serialize.deserialize_output(span_data["output"], meta)
1334
1318
  end
1335
1319
  end
1336
1320
 
1337
- # Extract input/output data from an external span's rawData.
1321
+ # Extract input/output data from a span's rawData.
1338
1322
  def extract_span_data(span)
1339
1323
  raw_data = span["rawData"] || {}
1340
1324
  span_data = raw_data["span_data"] || {}
@@ -20,6 +20,26 @@ module Bitfab
20
20
  # Recursion guard for cyclic graphs and pathologically nested structures.
21
21
  MAX_SERIALIZE_DEPTH = 16
22
22
 
23
+ # A running count of bytes the JSON encoding of the walk's output is known
24
+ # to need (`used`), and of every byte ever counted, including counts later
25
+ # taken back when a part that raised was stubbed (`charged`). It is also
26
+ # the catch tag the walk throws to once `used` passes MAX_SERIALIZED_BYTES.
27
+ Budget = Struct.new(:used, :charged)
28
+
29
+ # Once this many bytes have been counted in total, counting stops and the
30
+ # walk finishes uncounted, so parts that raise and are stubbed over and
31
+ # over cannot make the walk do unbounded work before the size check.
32
+ MAX_COUNTED_BYTES = 4 * MAX_SERIALIZED_BYTES
33
+
34
+ # String encodings whose bytes JSON writes out one for one (or rejects). In
35
+ # any other encoding a character can take more bytes than its UTF-8 form,
36
+ # so only the character count is a safe floor there.
37
+ BYTE_FOR_BYTE_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII, Encoding::BINARY].freeze
38
+
39
+ # The dropped entry for a value replaced by a placeholder because it is
40
+ # past MAX_SERIALIZED_BYTES.
41
+ TOO_LARGE = "too_large"
42
+
23
43
  module_function
24
44
 
25
45
  # Serialize a value for JSON storage (human-readable).
@@ -43,22 +63,43 @@ module Bitfab
43
63
  # non-empty dropped means the capture is lossy, so the send boundary marks
44
64
  # the span serialization_degraded (non-replayable) instead of shipping it as
45
65
  # if it round-trips. Mirrors the Python/TS SDKs' report serializers.
66
+ #
67
+ # The walk counts a lower bound on the JSON size of what it has built so
68
+ # far and gives up as soon as that passes MAX_SERIALIZED_BYTES, so a value
69
+ # holding huge arrays or strings is stubbed without first being copied and
70
+ # encoded in full. The count never exceeds the real size, so a value that
71
+ # fits is never stubbed; the JSON.dump check afterwards still catches values
72
+ # whose real size passes the cap while the count does not.
46
73
  def serialize_value_with_report(value)
47
74
  dropped = []
48
- result = serialize_value_inner(value, 0, dropped)
49
- if oversized?(result)
50
- dropped << "too_large"
51
- return [unserializable_stub(value, "too_large"), dropped]
75
+ budget = Budget.new(1, 1)
76
+ result = catch(budget) { serialize_value_inner(value, 0, dropped, budget) }
77
+ reason = if budget.used > MAX_SERIALIZED_BYTES
78
+ "too_large_over_#{MAX_SERIALIZED_BYTES}_bytes"
79
+ elsif oversized?(result)
80
+ "too_large"
52
81
  end
53
- [result, dropped]
82
+ return [result, dropped] unless reason
83
+
84
+ dropped << TOO_LARGE
85
+ [unserializable_stub(value, reason), dropped]
54
86
  rescue StandardError, SystemStackError
55
87
  [unserializable_stub(value, "unexpected_error"), [class_name(value)]]
56
88
  end
57
89
 
58
- def serialize_value_inner(value, depth, dropped = [])
90
+ # `budget`, when given, counts the bytes this call's output needs beyond the
91
+ # one byte its caller already counted for it (every JSON value takes at
92
+ # least one), and throws to the budget once the total passes the cap.
93
+ # A hash entry costs its quoted key, a colon, a comma or brace, and the
94
+ # value's byte. An array or set element costs its byte plus a comma or
95
+ # bracket; a plain Array or Set is charged for every element before any is
96
+ # copied, so a huge one is turned away at once. When a part raises, its
97
+ # stub replaces whatever that part had counted.
98
+ def serialize_value_inner(value, depth, dropped = [], budget = nil)
99
+ mark = budget&.used
59
100
  if depth > MAX_SERIALIZE_DEPTH
60
101
  dropped << "max_depth"
61
- return "<unserializable: max_depth>"
102
+ return counted_string(budget, "<unserializable: max_depth>")
62
103
  end
63
104
 
64
105
  case value
@@ -70,47 +111,95 @@ module Bitfab
70
111
  value
71
112
  else
72
113
  dropped << "non_finite_float"
73
- "<unserializable: non_finite_float>"
114
+ counted_string(budget, "<unserializable: non_finite_float>")
74
115
  end
75
- when nil, true, false, Integer, String
116
+ when nil, true, false, Integer
76
117
  value
118
+ when String
119
+ counted_string(budget, value)
77
120
  when Hash
78
- value.each_with_object({}) do |(k, v), acc|
79
- acc[safe_to_s(k)] = serialize_value_inner(v, depth + 1, dropped)
121
+ serialize_hash_inner(value, depth, dropped, budget)
122
+ when Array, Set
123
+ plain = value.instance_of?(Array) || value.instance_of?(Set)
124
+ charge(budget, 2 * value.size) if plain
125
+ value.map do |v|
126
+ charge(budget, 2) unless plain
127
+ serialize_value_inner(v, depth + 1, dropped, budget)
80
128
  end
81
- when Array
82
- value.map { |v| serialize_value_inner(v, depth + 1, dropped) }
83
- when Set
84
- value.map { |v| serialize_value_inner(v, depth + 1, dropped) }
85
129
  when Time, DateTime
86
- safely_call(value, "iso8601", 3) { safe_to_s(value) }
130
+ counted_string(budget, safely_call(value, "iso8601", 3) { safe_to_s(value) })
87
131
  when Date
88
- safe_to_s(value)
132
+ counted_string(budget, safe_to_s(value))
89
133
  when Symbol
90
- value.to_s
134
+ counted_string(budget, value.to_s)
91
135
  else
92
136
  if value.respond_to?(:to_h)
93
137
  h = safely_call(value, "to_h") do
94
138
  dropped << class_name(value)
95
- return unserializable_stub(value, "to_h_raised")
139
+ return counted_string(budget, unserializable_stub(value, "to_h_raised"))
96
140
  end
97
- serialize_value_inner(h, depth + 1, dropped)
141
+ serialize_value_inner(h, depth + 1, dropped, budget)
98
142
  elsif value.respond_to?(:to_a)
99
143
  a = safely_call(value, "to_a") do
100
144
  dropped << class_name(value)
101
- return unserializable_stub(value, "to_a_raised")
145
+ return counted_string(budget, unserializable_stub(value, "to_a_raised"))
102
146
  end
103
- serialize_value_inner(a, depth + 1, dropped)
147
+ serialize_value_inner(a, depth + 1, dropped, budget)
104
148
  else
105
149
  # An arbitrary object with no structured conversion: stringifying it is
106
150
  # a lossy capture (a repr, not its data), so report it as dropped.
107
151
  dropped << class_name(value)
108
- safe_to_s(value)
152
+ counted_string(budget, safe_to_s(value))
109
153
  end
110
154
  end
111
155
  rescue StandardError, SystemStackError
112
156
  dropped << class_name(value)
113
- unserializable_stub(value, "inner_error")
157
+ budget.used = mark if budget
158
+ counted_string(budget, unserializable_stub(value, "inner_error"))
159
+ end
160
+
161
+ # Serialize a hash's entries into a plain Hash keyed by each key's string.
162
+ # Keys that stringify alike (such as "data" and :data) share one output
163
+ # key holding the last of their values, so each output key is counted
164
+ # once, only the value that is kept is walked, and the key keeps the place
165
+ # of its first occurrence.
166
+ def serialize_hash_inner(hash, depth, dropped, budget)
167
+ entries = []
168
+ last_index = {}
169
+ hash.each do |k, v|
170
+ key = safe_to_s(k)
171
+ charge(budget, string_floor(key) + 5) unless last_index.key?(key)
172
+ last_index[key] = entries.size
173
+ entries << [key, v]
174
+ end
175
+ entries.each_with_index.with_object({}) do |((key, v), index), acc|
176
+ acc[key] = (last_index[key] == index) ? serialize_value_inner(v, depth + 1, dropped, budget) : nil
177
+ end
178
+ end
179
+
180
+ # Add `bytes` to the running count and stop the walk once it passes the cap.
181
+ def charge(budget, bytes)
182
+ return unless budget && budget.charged <= MAX_COUNTED_BYTES
183
+
184
+ budget.used += bytes
185
+ budget.charged += bytes
186
+ throw budget if budget.used > MAX_SERIALIZED_BYTES
187
+ end
188
+
189
+ # Charge for a string the walk emits (its quotes beyond the byte the caller
190
+ # counted) and return it.
191
+ def counted_string(budget, str)
192
+ charge(budget, string_floor(str) + 1)
193
+ str
194
+ end
195
+
196
+ # The fewest bytes JSON can write for the characters of `str`, quotes
197
+ # aside. A String subclass may encode itself through its own to_json, so it
198
+ # counts as nothing.
199
+ def string_floor(str)
200
+ return 0 unless str.instance_of?(String)
201
+
202
+ BYTE_FOR_BYTE_ENCODINGS.include?(str.encoding) ? str.bytesize : str.length
114
203
  end
115
204
 
116
205
  # Call `value.<method>` and return the result, or yield to the fallback
@@ -153,23 +242,42 @@ module Bitfab
153
242
  end
154
243
 
155
244
  # Like serialize_inputs, but also returns the accumulated dropped list so the
156
- # send boundary can mark a lossy capture non-replayable.
245
+ # send boundary can mark a lossy capture non-replayable. The third element
246
+ # is [args, kwargs] with every value past MAX_SERIALIZED_BYTES swapped for
247
+ # its placeholder (the originals when none is), so the type-preserving
248
+ # capture never copies a value the readable capture already gave up on.
157
249
  def serialize_inputs_with_report(args, kwargs = {})
158
250
  dropped = []
159
- serialized = args.map do |arg|
251
+ kept_args = args
252
+ serialized = args.each_with_index.map do |arg, index|
160
253
  result, arg_dropped = serialize_value_with_report(arg)
161
254
  dropped.concat(arg_dropped)
255
+ if too_large?(arg_dropped)
256
+ kept_args = kept_args.dup if kept_args.equal?(args)
257
+ kept_args[index] = result
258
+ end
162
259
  result
163
260
  end
261
+ kept_kwargs = kwargs
164
262
  unless kwargs.empty?
165
263
  kw = kwargs.each_with_object({}) do |(k, v), acc|
166
264
  result, v_dropped = serialize_value_with_report(v)
167
265
  dropped.concat(v_dropped)
266
+ if too_large?(v_dropped)
267
+ kept_kwargs = kept_kwargs.dup if kept_kwargs.equal?(kwargs)
268
+ kept_kwargs[k] = result
269
+ end
168
270
  acc[safe_to_s(k)] = result
169
271
  end
170
272
  serialized << kw
171
273
  end
172
- [serialized, dropped]
274
+ [serialized, dropped, [kept_args, kept_kwargs]]
275
+ end
276
+
277
+ # Whether a dropped list from serialize_value_with_report means the value
278
+ # itself was replaced by a placeholder for being past MAX_SERIALIZED_BYTES.
279
+ def too_large?(dropped)
280
+ dropped.include?(TOO_LARGE)
173
281
  end
174
282
 
175
283
  # Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction.
@@ -198,7 +198,7 @@ module Bitfab
198
198
  end
199
199
 
200
200
  def trace_id(payload)
201
- payload["traceId"] || payload["sourceTraceId"] || payload.dig("externalTrace", "id") || payload["id"]
201
+ payload["traceId"] || payload["sourceTraceId"] || payload.dig("rawTrace", "id") || payload.dig("externalTrace", "id") || payload["id"]
202
202
  end
203
203
 
204
204
  def framework?(payload)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.74.1"
4
+ VERSION = "0.74.3"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -146,9 +146,6 @@ module Bitfab
146
146
  lease = ctx[:db_branch_lease]
147
147
  return unless lease
148
148
 
149
- # Surface the Bitfab trace ID (what the customer sees in the dashboard),
150
- # not the external one. Falling back to the external ID keeps replays from
151
- # external sources working until that path is fully wired.
152
149
  trace_id = ctx[:source_bitfab_trace_id] || ctx[:input_source_trace_id]
153
150
  return unless trace_id
154
151
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.74.1
4
+ version: 0.74.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team