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 +4 -4
- data/lib/bitfab/client.rb +14 -8
- data/lib/bitfab/compress.rb +15 -4
- data/lib/bitfab/http_client.rb +14 -5
- data/lib/bitfab/otel.rb +50 -9
- data/lib/bitfab/payload_budget.rb +16 -14
- data/lib/bitfab/replay.rb +52 -23
- data/lib/bitfab/serialize.rb +6 -4
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +7 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49b714ee6d468fc34c0c6009802c5736d33f03275f06d7d7148635b5d70b7caa
|
|
4
|
+
data.tar.gz: 3816843ddea8722c0222cfc0bed5c65ea05f5abb5ff764233e5ab302d609f2d2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
136
|
-
# it finishes, with a running-totals hash
|
|
137
|
-
# errored: }.
|
|
138
|
-
#
|
|
139
|
-
# items whose method ran (:succeeded) from items
|
|
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
|
data/lib/bitfab/compress.rb
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
35
|
+
PreparedRequest.new(compressed, "gzip", size, compressed.bytesize)
|
|
25
36
|
rescue
|
|
26
|
-
|
|
37
|
+
PreparedRequest.new(body, nil, body.bytesize, body.bytesize)
|
|
27
38
|
end
|
|
28
39
|
end
|
|
29
40
|
end
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -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
|
-
|
|
74
|
-
req
|
|
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,
|
|
283
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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(
|
|
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,
|
|
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(
|
|
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
|
|
23
|
-
#
|
|
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 <=
|
|
53
|
-
return false if size + 2 >
|
|
54
|
+
return true if size * 2 + 2 <= max_bytes
|
|
55
|
+
return false if size + 2 > max_bytes
|
|
54
56
|
|
|
55
|
-
carrier_byte_length(body) <=
|
|
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
|
-
"#{
|
|
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
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
387
|
-
#
|
|
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
|
-
|
|
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,
|
|
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
|
-
#
|
|
639
|
-
#
|
|
640
|
-
#
|
|
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
|
|
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
|
-
#
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
|
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 }
|
data/lib/bitfab/serialize.rb
CHANGED
|
@@ -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::
|
|
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)
|
|
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 #{
|
|
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
|
)
|
data/lib/bitfab/version.rb
CHANGED
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
|
|
148
|
-
#
|
|
149
|
-
# progress
|
|
150
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|