bitfab 0.36.3 → 0.36.5

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: deea3052760a7b677216225f0cd030f4931995b1d10e6b7396c97a57a27ab9c1
4
- data.tar.gz: 5f64a512db5c8c79f092e5bbf92e16adfc2e20a4a07c869eafab48fb42db5665
3
+ metadata.gz: 49b714ee6d468fc34c0c6009802c5736d33f03275f06d7d7148635b5d70b7caa
4
+ data.tar.gz: 3816843ddea8722c0222cfc0bed5c65ea05f5abb5ff764233e5ab302d609f2d2
5
5
  SHA512:
6
- metadata.gz: f9024ab4975b3963e4d2c91aa903b3e4bc7880791aa1f0a4132a9ee847e54e2132339246cbc1067beba8a2130e813738474fcc40a38a1d3b9a0e19ba92633834
7
- data.tar.gz: b3c4b7c252a566192362e6d1a3e4d31bcad55f752a70527fce5de87ebc9ab75c4839a02fd5d67589823ee36003f3fcb49d5bdade0a315dc8a22971b9769ea9c3
6
+ metadata.gz: 1f5504e7d7622ccf55145b9124ee0d8ca42f774bdaea720a92382af42148bbf16afb72708d673fb359b37a9840364a770e4a1a87003f016aff2ebd1a59a52b38
7
+ data.tar.gz: db5fbc39adf358e640ae315348eb15ceb5798c67747d092eaaf37651cf9a89542ae9c4d8899a44ebfd4425ba623598e9e5af382dec11f119a917d52cf9ccb0dd
data/lib/bitfab/client.rb CHANGED
@@ -132,21 +132,27 @@ module Bitfab
132
132
  # +:min_cu+/+:max_cu+ size the branch compute in Neon Compute Units and
133
133
  # +:warmup_sql+ warms its cache. Read the resolved branch inside the
134
134
  # replayed method with +Bitfab.current_replay_branch+.
135
- # @param on_progress [#call, nil] optional callback invoked once per item as
136
- # it finishes, with a running-totals hash { completed:, total:, succeeded:,
137
- # errored: }. Use it to render replay progress (e.g. a terminal progress
138
- # bar). Replay does not know pass/fail yet, so the totals only distinguish
139
- # items whose method ran (:succeeded) from items that raised (:errored).
140
- # A raising callback never crashes the run.
135
+ # @param on_item_finish [#call, nil] optional callback invoked exactly once
136
+ # per item as it finishes, always with a running-totals hash containing
137
+ # { completed:, total:, succeeded:, errored:, item: }. It never receives a
138
+ # whole-run completion event. Replay does not know pass/fail yet, so the
139
+ # totals only distinguish items whose method ran (:succeeded) from items
140
+ # that raised (:errored). A raising callback never crashes the run.
141
+ # @param on_progress [#call, nil] deprecated compatibility callback. It
142
+ # receives the same per-item events plus the legacy item-less +"complete"+
143
+ # event. Ignored when +on_item_finish+ is also provided.
144
+ # @param on_item_start [#call, nil] optional callback invoked when a worker
145
+ # begins processing each item. Pair it with on_item_finish to distinguish
146
+ # queued work from in-flight work. A raising callback never crashes the run.
141
147
  # @return [Hash] with :items, :test_run_id, :test_run_url
142
148
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
143
149
  name: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked",
144
- adapt_inputs: nil, mock_override: nil, db_branch: nil, on_progress: nil)
150
+ adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil)
145
151
  Replay.run(
146
152
  self, receiver, method_name,
147
153
  trace_function_key:, limit:, trace_ids:, name:, max_concurrency:,
148
154
  code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, grader_ids:, mock:, adapt_inputs:,
149
- mock_override:, db_branch:,
155
+ mock_override:, db_branch:, on_item_start:, on_item_finish:,
150
156
  on_progress:
151
157
  )
152
158
  end
@@ -12,18 +12,29 @@ module Bitfab
12
12
  # batches) ride uncompressed.
13
13
  MIN_COMPRESSED_BYTES = 8_192
14
14
 
15
+ PreparedRequest = Struct.new(:body, :content_encoding, :raw_bytes, :wire_bytes)
16
+
15
17
  module_function
16
18
 
17
19
  # Returns the body to send and the Content-Encoding it carries, or nil when
18
20
  # the body is sent as-is. Compression is best-effort: any failure sends the
19
21
  # original body rather than dropping the span.
20
22
  def encode_request_body(body)
21
- return [body, nil] if ENV[DISABLE_COMPRESSION_ENV]
22
- return [body, nil] if body.bytesize < MIN_COMPRESSED_BYTES
23
+ prepared = prepare_request_body(body)
24
+ [prepared.body, prepared.content_encoding]
25
+ end
26
+
27
+ def prepare_request_body(body)
28
+ size = body.bytesize
29
+ return PreparedRequest.new(body, nil, size, size) if ENV[DISABLE_COMPRESSION_ENV]
30
+ return PreparedRequest.new(body, nil, size, size) if size < MIN_COMPRESSED_BYTES
31
+
32
+ compressed = Zlib.gzip(body)
33
+ return PreparedRequest.new(body, nil, size, size) if compressed.bytesize >= size
23
34
 
24
- [Zlib.gzip(body), "gzip"]
35
+ PreparedRequest.new(compressed, "gzip", size, compressed.bytesize)
25
36
  rescue
26
- [body, nil]
37
+ PreparedRequest.new(body, nil, body.bytesize, body.bytesize)
27
38
  end
28
39
  end
29
40
  end
@@ -58,6 +58,16 @@ module Bitfab
58
58
  # POST an already-encoded body. The span transport encodes its own batches,
59
59
  # so routing them back through #request would encode the same data twice.
60
60
  def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1)
61
+ send_prepared(
62
+ endpoint,
63
+ Compress.prepare_request_body(body),
64
+ timeout:,
65
+ max_retries:,
66
+ retry_delay:
67
+ )
68
+ end
69
+
70
+ def send_prepared(endpoint, prepared, timeout: nil, max_retries: 1, retry_delay: 0.1)
61
71
  uri = URI("#{@service_url}#{endpoint}")
62
72
  request_timeout = timeout || @timeout
63
73
 
@@ -70,9 +80,8 @@ module Bitfab
70
80
  http.read_timeout = request_timeout
71
81
 
72
82
  req = Net::HTTP::Post.new(uri.path, headers)
73
- encoded_body, content_encoding = Compress.encode_request_body(body)
74
- req["Content-Encoding"] = content_encoding if content_encoding
75
- req.body = encoded_body
83
+ req["Content-Encoding"] = prepared.content_encoding if prepared.content_encoding
84
+ req.body = prepared.body
76
85
 
77
86
  response = http.request(req)
78
87
 
@@ -279,8 +288,8 @@ module Bitfab
279
288
  end
280
289
  end
281
290
 
282
- def send_transport_request(endpoint, body, timeout)
283
- send_encoded(endpoint, body, timeout:, max_retries: 1)
291
+ def send_transport_request(endpoint, request, timeout)
292
+ send_prepared(endpoint, request, timeout:, max_retries: 1)
284
293
  end
285
294
 
286
295
  # Normalize each entry to a hash with stable string keys, accepting either
data/lib/bitfab/otel.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "time"
6
6
  require "opentelemetry/sdk"
7
7
 
8
+ require_relative "compress"
8
9
  require_relative "serialize"
9
10
  require_relative "version"
10
11
  require_relative "warn_once"
@@ -22,6 +23,7 @@ module Bitfab
22
23
  MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
23
24
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
24
25
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
26
+ MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
25
27
  MAX_QUEUE_SIZE = 8_192
26
28
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
27
29
  DIRECT_MAX_REQUEST_BATCH_SIZE = 8
@@ -314,13 +316,35 @@ module Bitfab
314
316
  end
315
317
 
316
318
  def send_batch(envelope, batch)
317
- if batch.size > @max_request_bytes
318
- warn_oversized(batch.spans.size)
319
- return false
320
- end
319
+ request_spans = batch.spans
320
+ request_raw_bytes = batch.size
321
+ already_trimmed = false
322
+ loop do
323
+ if request_raw_bytes <= MAX_DECOMPRESSED_REQUEST_BYTES
324
+ prepared = Compress.prepare_request_body(Encoder.encode_request(envelope, request_spans))
325
+ if prepared.wire_bytes <= @max_request_bytes
326
+ send_with_retries(prepared)
327
+ return true
328
+ end
329
+ end
321
330
 
322
- send_with_retries(Encoder.encode_request(envelope, batch.spans))
323
- true
331
+ if batch.spans.size != 1
332
+ warn_oversized(batch.spans.size)
333
+ return false
334
+ end
335
+ if already_trimmed
336
+ warn_oversized(1)
337
+ return false
338
+ end
339
+ trimmed = Encoder.trim_span(batch.spans.first)
340
+ if trimmed.nil?
341
+ warn_oversized(1)
342
+ return false
343
+ end
344
+ request_spans = [trimmed]
345
+ request_raw_bytes = envelope.size + trimmed.size
346
+ already_trimmed = true
347
+ end
324
348
  rescue PayloadTooLargeError
325
349
  warn_oversized(batch.spans.size)
326
350
  false
@@ -343,11 +367,11 @@ module Bitfab
343
367
  )
344
368
  end
345
369
 
346
- def send_with_retries(body)
370
+ def send_with_retries(request)
347
371
  attempt = 0
348
372
  begin
349
373
  attempt += 1
350
- response = @direct_sender.call(OTLP_TRACES_ENDPOINT, body, EXPORT_TIMEOUT_MILLIS / 1000.0)
374
+ response = @direct_sender.call(OTLP_TRACES_ENDPOINT, request, EXPORT_TIMEOUT_MILLIS / 1000.0)
351
375
  check_partial_success(response)
352
376
  rescue PartialSuccessError
353
377
  raise
@@ -456,7 +480,10 @@ module Bitfab
456
480
  # Encoding is the expensive part of a submit and needs no mutual
457
481
  # exclusion, so it stays outside the lock.
458
482
  name = Encoder.span_name(operation, payload)
459
- encoded_payload = Serialize.safe_generate(payload)
483
+ encoded_payload = Serialize.safe_generate(
484
+ payload,
485
+ max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
486
+ )
460
487
  started_at = Encoder.timestamp(payload, "started_at")
461
488
  ended_at = Encoder.timestamp(payload, "ended_at")
462
489
  errored = Encoder.error?(payload)
@@ -616,6 +643,20 @@ module Bitfab
616
643
  EncodedSpan.new(encoded, encoded.bytesize)
617
644
  end
618
645
 
646
+ def trim_span(span)
647
+ carrier = JSON.parse(span.encoded)
648
+ attribute = carrier.fetch("attributes").find { |entry| entry["key"] == PAYLOAD_ATTRIBUTE }
649
+ return nil if attribute.nil?
650
+
651
+ value = attribute.fetch("value")
652
+ payload = JSON.parse(value.fetch("stringValue"))
653
+ value["stringValue"] = Serialize.safe_generate(payload)
654
+ encoded = JSON.generate(carrier)
655
+ EncodedSpan.new(encoded, encoded.bytesize)
656
+ rescue KeyError, JSON::ParserError, TypeError
657
+ nil
658
+ end
659
+
619
660
  def request_envelope(first)
620
661
  scope = first.instrumentation_scope
621
662
  resource = JSON.generate({"attributes" => attributes(first.resource.attribute_enumerator.to_h)})
@@ -19,10 +19,12 @@ module Bitfab
19
19
  # on a body sized exactly to a 2.4 MB cap, prose produced a 2.4 MB carrier but
20
20
  # backslash-dense content produced 4.8 MB, which the exporter dropped.
21
21
  #
22
- # 2.8 MB leaves room beneath the 3 MB request ceiling for the span and request
23
- # envelopes wrapped around the attribute.
22
+ # The normal 2.8 MB fallback leaves room beneath the 3 MB wire target. Trace
23
+ # transport may first preserve a carrier up to 7.8 MB when its single-span
24
+ # request compresses below that target and stays under the 8 MB raw ceiling.
24
25
  module PayloadBudget
25
26
  MAX_SPAN_CARRIER_BYTES = 2_800_000
27
+ MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES = 7_800_000
26
28
 
27
29
  # Span fields that identify the span rather than carry user data. Trimming
28
30
  # one would leave a span that no longer says what it is, so they stay
@@ -47,23 +49,23 @@ module Bitfab
47
49
  # can at most double it, so anything under half the budget always fits and
48
50
  # anything past the budget never does. Ordinary spans settle on the first
49
51
  # comparison and never pay for the scan.
50
- def fits_carrier_budget?(body)
52
+ def fits_carrier_budget?(body, max_bytes = MAX_SPAN_CARRIER_BYTES)
51
53
  size = body.bytesize
52
- return true if size * 2 + 2 <= MAX_SPAN_CARRIER_BYTES
53
- return false if size + 2 > MAX_SPAN_CARRIER_BYTES
54
+ return true if size * 2 + 2 <= max_bytes
55
+ return false if size + 2 > max_bytes
54
56
 
55
- carrier_byte_length(body) <= MAX_SPAN_CARRIER_BYTES
57
+ carrier_byte_length(body) <= max_bytes
56
58
  end
57
59
 
58
60
  # Return a body within the budget, plus the fields that had to be stubbed.
59
- def enforce(payload, body, &encode)
60
- return [body, []] if fits_carrier_budget?(body)
61
+ def enforce(payload, body, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
62
+ return [body, []] if fits_carrier_budget?(body, max_bytes)
61
63
  return [body, []] unless payload.is_a?(Hash)
62
64
 
63
- trimmed_payload, trimmed = trim(payload, &encode)
65
+ trimmed_payload, trimmed = trim(payload, max_bytes, &encode)
64
66
  return [body, []] if trimmed_payload.nil?
65
67
 
66
- mark_trimmed(trimmed_payload, trimmed)
68
+ mark_trimmed(trimmed_payload, trimmed, max_bytes)
67
69
  [encode.call(trimmed_payload), trimmed]
68
70
  rescue
69
71
  [body, []]
@@ -73,7 +75,7 @@ module Bitfab
73
75
  # Returns [trimmed_payload, trimmed_keys], or nil when nothing could be
74
76
  # trimmed: the caller then ships the oversized body and lets the exporter
75
77
  # report the drop, which still beats silently emptying a span.
76
- def trim(payload, &encode)
78
+ def trim(payload, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
77
79
  copy, containers = clone_trimmable(payload)
78
80
  candidates = collect_candidates(containers)
79
81
  return nil if candidates.empty?
@@ -83,7 +85,7 @@ module Bitfab
83
85
  container[key] = "<unserializable: too_large_#{size}_bytes>"
84
86
  trimmed << key
85
87
  body = encode.call(copy)
86
- return [copy, trimmed] if fits_carrier_budget?(body)
88
+ return [copy, trimmed] if fits_carrier_budget?(body, max_bytes)
87
89
  end
88
90
  nil
89
91
  end
@@ -135,7 +137,7 @@ module Bitfab
135
137
 
136
138
  # Record the trim in the payload's own errors, which is what the server
137
139
  # reads to flag a trace as incomplete.
138
- def mark_trimmed(payload, trimmed)
140
+ def mark_trimmed(payload, trimmed, max_bytes)
139
141
  key = payload.key?(:errors) ? :errors : "errors"
140
142
  existing = payload[key]
141
143
  errors = existing.is_a?(Array) ? existing.dup : []
@@ -143,7 +145,7 @@ module Bitfab
143
145
  "source" => "sdk",
144
146
  "step" => "payload_budget",
145
147
  "error" => "trimmed oversized field(s) to fit the " \
146
- "#{MAX_SPAN_CARRIER_BYTES}-byte span carrier budget: " \
148
+ "#{max_bytes}-byte span carrier budget: " \
147
149
  "#{trimmed.uniq.join(", ")}"
148
150
  }
149
151
  payload[key] = errors
data/lib/bitfab/replay.rb CHANGED
@@ -201,11 +201,11 @@ module Bitfab
201
201
  # and ceiling in Neon Compute Units, and +:warmup_sql+ warms the branch's
202
202
  # cache before the replayed method sees it. Read the resolved branch
203
203
  # inside the method with +Bitfab.current_replay_branch+.
204
- # @param on_progress [#call, nil] optional callback invoked once per item as
204
+ # @param on_item_finish [#call, nil] optional callback invoked once per item as
205
205
  # it finishes, with a running-totals hash { completed:, total:, succeeded:,
206
206
  # errored:, item: } where item is { trace_id:, original_trace_id:,
207
207
  # original_span_id:, error:, duration_ms: } for the single item that just
208
- # settled (source_trace_id/source_span_id remain as deprecated aliases).
208
+ # finished (source_trace_id/source_span_id remain as deprecated aliases).
209
209
  # trace_id is the new server replay trace id, written in after the run
210
210
  # completes (nil during progress callbacks); original_trace_id is the
211
211
  # ORIGINAL (historical) trace that was replayed, error is that item's replay
@@ -213,14 +213,22 @@ module Bitfab
213
213
  # duration_ms is how long that one trace took to replay. Use it to
214
214
  # render replay progress (e.g. a per-trace log). A raising callback never
215
215
  # crashes the run.
216
+ # @param on_progress [#call, nil] deprecated compatibility callback. It
217
+ # receives the same per-item finish events plus the legacy whole-run
218
+ # +"complete"+ event. Ignored when +on_item_finish+ is also provided.
219
+ # @param on_item_start [#call, nil] optional callback invoked when a worker
220
+ # begins processing each item, before replay setup and customer code run.
221
+ # Pair it with on_item_finish to distinguish queued work from in-flight work.
222
+ # A raising callback never crashes the run.
216
223
  # @return [Hash] with :items, :test_run_id, :test_run_url
217
224
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
218
225
  max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
219
226
  dataset_id: nil, grader_ids: nil, mock: "marked",
220
- adapt_inputs: nil, mock_override: nil, db_branch: nil, on_progress: nil)
227
+ adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil)
221
228
  unless MOCK_STRATEGIES.include?(mock.to_s)
222
229
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
223
230
  end
231
+ item_finish_callback = on_item_finish || on_progress
224
232
  if trace_ids
225
233
  raise ArgumentError, "trace_ids must contain at least one trace ID." if trace_ids.empty?
226
234
  if trace_ids.length > 100
@@ -296,7 +304,8 @@ module Bitfab
296
304
 
297
305
  result_items = if server_items.any?
298
306
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
299
- adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_progress:,
307
+ adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
308
+ on_item_finish: item_finish_callback,
300
309
  mock_overrides: resolved_overrides)
301
310
  else
302
311
  []
@@ -383,14 +392,12 @@ module Bitfab
383
392
  test_run_id:,
384
393
  test_run_url: full_test_run_url
385
394
  }
386
- # Persist the enriched result two ways so the Bitfab plugin never has to
387
- # parse the replay's stdout (which a dependency's logging can corrupt):
388
- # write it to BITFAB_REPLAY_RESULT_PATH when the plugin set that env var,
389
- # and stream a terminal "complete" progress event. The plugin prefers the
390
- # streamed event and falls back to the file. The event routes through
391
- # on_progress so only progress-reporting runs emit it.
395
+ # Persist the enriched result so the Bitfab plugin never has to parse the
396
+ # replay's stdout, which a dependency's logging can corrupt.
392
397
  write_replay_result_file(result)
393
- if on_progress
398
+ # Preserve the legacy terminal event only for on_progress. on_item_finish
399
+ # is strictly item-scoped, so every invocation always includes an item.
400
+ if on_item_finish.nil? && on_progress
394
401
  errored = result_items.count { |item| !item[:error].nil? }
395
402
  total = result_items.length
396
403
  begin
@@ -631,36 +638,56 @@ module Bitfab
631
638
 
632
639
  # Process all replay items, optionally in parallel using threads.
633
640
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
634
- adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_progress: nil,
641
+ adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil,
635
642
  mock_overrides: [])
636
643
  concurrency = max_concurrency || server_items.length
637
644
 
638
- # Reports running totals once per item as it settles. In the parallel
639
- # path it runs from worker threads, so the mutex both makes the counter
640
- # updates safe and serializes the user's callback (never called
641
- # concurrently). A raising callback is swallowed: progress UI must never
642
- # crash the run.
645
+ # Lifecycle callbacks run from worker threads in the parallel path, so the
646
+ # mutex makes counter updates safe and serializes each callback. A raising
647
+ # callback is swallowed because progress UI must never crash the run.
643
648
  total = server_items.length
644
649
  progress_mutex = Mutex.new
650
+ started = 0
645
651
  completed = 0
646
652
  succeeded = 0
647
653
  errored = 0
648
- # Each event carries the single item that just settled so a progress UI
654
+ # Each event carries the single item that just finished so a progress UI
649
655
  # can render per-trace pass/fail as the run streams. The item's :trace_id
650
656
  # is nil at this stage: the server replay trace id isn't known until run()
651
657
  # writes it in after complete_replay, and the client correlation id is
652
658
  # never surfaced. original_trace_id (the historical trace being replayed,
653
659
  # taken from the server item) is what a UI keys on to identify what just
654
- # settled. source_trace_id/source_span_id are kept as deprecated aliases.
655
- report = lambda do |result, original_trace_id, original_span_id, test_run_id|
656
- return unless on_progress
660
+ # finished. source_trace_id/source_span_id are kept as deprecated aliases.
661
+ report_start = lambda do |original_trace_id, original_span_id|
662
+ progress_mutex.synchronize do
663
+ started += 1
664
+ next unless on_item_start
665
+
666
+ begin
667
+ on_item_start.call({
668
+ type: "started", test_run_id:, started:, completed:, total:, succeeded:, errored:,
669
+ item: {
670
+ original_trace_id:,
671
+ original_span_id:,
672
+ source_trace_id: original_trace_id,
673
+ source_span_id: original_span_id
674
+ }
675
+ })
676
+ rescue => e
677
+ warn "Bitfab: replay on_item_start callback raised: #{e.message}"
678
+ end
679
+ end
680
+ end
657
681
 
682
+ report = lambda do |result, original_trace_id, original_span_id, test_run_id|
658
683
  progress_mutex.synchronize do
659
684
  completed += 1
660
685
  error = result[:error]
661
686
  error.nil? ? (succeeded += 1) : (errored += 1)
687
+ next unless on_item_finish
688
+
662
689
  begin
663
- on_progress.call({
690
+ on_item_finish.call({
664
691
  test_run_id:, completed:, total:, succeeded:, errored:,
665
692
  item: {
666
693
  trace_id: result[:trace_id],
@@ -682,13 +709,14 @@ module Bitfab
682
709
  }
683
710
  })
684
711
  rescue => e
685
- warn "Bitfab: replay on_progress callback raised: #{e.message}"
712
+ warn "Bitfab: replay on_item_finish callback raised: #{e.message}"
686
713
  end
687
714
  end
688
715
  end
689
716
 
690
717
  if concurrency <= 1
691
718
  server_items.map do |item|
719
+ report_start.call(original_trace_id_of(item), original_span_id_of(item))
692
720
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
693
721
  adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
694
722
  report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
@@ -706,6 +734,7 @@ module Bitfab
706
734
  item, idx = work_mutex.synchronize { work_queue.shift }
707
735
  break unless item
708
736
 
737
+ report_start.call(original_trace_id_of(item), original_span_id_of(item))
709
738
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
710
739
  adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
711
740
  results_mutex.synchronize { results[idx] = result }
@@ -14,7 +14,7 @@ module Bitfab
14
14
  # further. It is deliberately the same number as the whole-span budget: one
15
15
  # legitimately large value may use the entire budget, and PayloadBudget is
16
16
  # what enforces the total once every field is in.
17
- MAX_SERIALIZED_BYTES = PayloadBudget::MAX_SPAN_CARRIER_BYTES
17
+ MAX_SERIALIZED_BYTES = PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
18
18
 
19
19
  # Recursion guard for cyclic graphs and pathologically nested structures.
20
20
  MAX_SERIALIZE_DEPTH = 16
@@ -260,18 +260,20 @@ module Bitfab
260
260
  # raises and stubs strays) instead of letting JSON.generate raise and drop
261
261
  # the whole span/trace silently. A degraded payload warns loudly so the
262
262
  # trace isn't quietly left incomplete or not replayable.
263
- def safe_generate(payload)
263
+ def safe_generate(payload, max_carrier_bytes: PayloadBudget::MAX_SPAN_CARRIER_BYTES)
264
264
  # Budget the value that was actually encoded, not the caller's original: a
265
265
  # cyclic or otherwise non-encodable field cannot be sized (JSON.generate
266
266
  # raises on it), so on the original graph the biggest field is skipped as
267
267
  # a trim candidate and the oversized body ships anyway. The sanitized copy
268
268
  # has those values already replaced with stubs, so every field is sizeable.
269
269
  body, encoded = encode_unbounded(payload)
270
- body, trimmed = PayloadBudget.enforce(encoded, body) { |value| encode_unbounded(value).first }
270
+ body, trimmed = PayloadBudget.enforce(encoded, body, max_carrier_bytes) do |value|
271
+ encode_unbounded(value).first
272
+ end
271
273
  if trimmed.any?
272
274
  Bitfab.warn_once(
273
275
  "payload-over-budget",
274
- "a span payload exceeded the #{PayloadBudget::MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its " \
276
+ "a span payload exceeded the #{max_carrier_bytes}-byte carrier budget; its " \
275
277
  "largest field(s) (#{trimmed.uniq.join(", ")}) were replaced with placeholders " \
276
278
  "so the span still ships. The span is incomplete and may not be replayable."
277
279
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.3"
4
+ VERSION = "0.36.5"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -144,20 +144,18 @@ module Bitfab
144
144
  CurrentTrace.new(entry[:trace_id])
145
145
  end
146
146
 
147
- # A ready-made on_progress callback for replay: writes one @@bitfab:progress
148
- # line per trace to stderr, which the Bitfab plugin polls to report live
149
- # progress while the replay runs in the background, so replay scripts never
150
- # hand-format the protocol. stdout stays the ReplayResult JSON. Never raises
151
- # (progress must not crash a run). The progress hash's item is forwarded
152
- # verbatim through to_json, so the plugin sees the source trace id, local
153
- # replay trace id, outputs, error, and duration of the item that just settled.
147
+ # A ready-made replay lifecycle reporter. Pass it as both on_item_start and
148
+ # on_item_finish so the Bitfab plugin sees in-flight and finished items. It writes
149
+ # @@bitfab:progress lines to stderr, leaving stdout for ReplayResult JSON, and
150
+ # never raises because progress reporting must not crash a run.
154
151
  #
155
152
  # @example
156
- # Bitfab.client.replay(..., on_progress: Bitfab.method(:report_replay_progress))
153
+ # reporter = Bitfab.method(:report_replay_progress)
154
+ # Bitfab.client.replay(..., on_item_start: reporter, on_item_finish: reporter)
157
155
  #
158
156
  # @param progress [Hash] running totals with keys completed, total,
159
157
  # succeeded, errored, and item (the object replay already passes to
160
- # on_progress)
158
+ # on_item_finish)
161
159
  def report_replay_progress(progress)
162
160
  warn "#{BITFAB_PROGRESS_PREFIX}#{Replay.json_safe(progress).to_json}"
163
161
  rescue
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.36.3
4
+ version: 0.36.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team