bitfab 0.51.9 → 0.51.10

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.
@@ -12,6 +12,9 @@ require_relative "transport"
12
12
  require_relative "trace_completion"
13
13
  require_relative "version"
14
14
  require_relative "warn_once"
15
+ require_relative "simulation_plan"
16
+ require_relative "span_origin"
17
+ require_relative "thread_propagation"
15
18
 
16
19
  module Bitfab
17
20
  # What a caller learns about one tracked trace once it takes it back.
@@ -45,49 +48,91 @@ module Bitfab
45
48
  @carrier_seq = 0
46
49
  @transport = nil
47
50
  @closed = false
51
+ @close_started = false
52
+ @close_result = nil
53
+ @close_condition = ConditionVariable.new
54
+ end
55
+
56
+ def configure_simulation_plan(enabled: true)
57
+ @simulation_plan = SimulationPlan.new(self, enabled:)
58
+ end
59
+
60
+ def refresh_simulation_plan
61
+ @simulation_plan&.refresh
62
+ end
63
+
64
+ def get_simulation_plan
65
+ get("/api/sdk/sim-plan", timeout: SimulationPlan::READ_TIMEOUT, connection_close: true)
48
66
  end
49
67
 
50
68
  # Flush and permanently close this client's tracing transport.
51
69
  # Returns true when everything it queued was delivered within the deadline.
52
70
  def close(timeout: 30)
53
- transport = @transport_mutex.synchronize do
54
- current = @transport
55
- @transport = nil
56
- @closed = true
57
- current
71
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [timeout, 0].max
72
+ @transport_mutex.synchronize do
73
+ while @close_started && @close_result.nil?
74
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ return false if remaining <= 0
76
+ @close_condition.wait(@transport_mutex, remaining)
77
+ end
78
+ return @close_result unless @close_result.nil?
79
+ @close_started = true
58
80
  end
59
- return true if transport.nil?
60
81
 
61
- transport.shutdown(timeout)
82
+ result = false
83
+ begin
84
+ remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
85
+ released = @simulation_plan.nil? || @simulation_plan.release([remaining / 2.0, SimulationPlan::READ_TIMEOUT].min)
86
+ @simulation_plan&.stop
87
+ remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
88
+ transport = @transport_mutex.synchronize do
89
+ current = @transport
90
+ @transport = nil
91
+ @closed = true
92
+ current
93
+ end
94
+ shutdown = transport.nil? || transport.shutdown(remaining)
95
+ result = released && shutdown
96
+ ensure
97
+ @transport_mutex.synchronize do
98
+ @close_result = result
99
+ @close_condition.broadcast
100
+ end
101
+ end
102
+ result
62
103
  end
63
104
 
64
105
  # Wait for the spans and traces this client queued to be delivered.
65
106
  def flush(timeout: 30)
107
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [timeout, 0].max
108
+ released = @simulation_plan.nil? || @simulation_plan.release([timeout / 2.0, SimulationPlan::READ_TIMEOUT].min)
109
+ timeout = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
66
110
  transport = @transport_mutex.synchronize { @transport }
67
- return true if transport.nil?
111
+ return released if transport.nil?
68
112
 
69
- transport.flush(timeout)
113
+ transport.flush(timeout) && released
70
114
  end
71
115
 
72
116
  # Make a POST request to the Bitfab API.
73
117
  # Returns parsed JSON response hash.
74
- def request(endpoint, payload, timeout: nil, max_retries: 1, retry_delay: 0.1)
75
- send_encoded(endpoint, Serialize.safe_generate(payload), timeout:, max_retries:, retry_delay:)
118
+ def request(endpoint, payload, timeout: nil, max_retries: 1, retry_delay: 0.1, method: "POST")
119
+ send_encoded(endpoint, Serialize.safe_generate(payload), timeout:, max_retries:, retry_delay:, method:)
76
120
  end
77
121
 
78
122
  # POST an already-encoded body. The span transport encodes its own batches,
79
123
  # so routing them back through #request would encode the same data twice.
80
- def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1)
124
+ def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1, method: "POST")
81
125
  send_prepared(
82
126
  endpoint,
83
127
  Compress.prepare_request_body(body),
84
128
  timeout:,
85
129
  max_retries:,
86
- retry_delay:
130
+ retry_delay:,
131
+ method:
87
132
  )
88
133
  end
89
134
 
90
- def send_prepared(endpoint, prepared, timeout: nil, max_retries: 1, retry_delay: 0.1)
135
+ def send_prepared(endpoint, prepared, timeout: nil, max_retries: 1, retry_delay: 0.1, method: "POST")
91
136
  uri = URI("#{@service_url}#{endpoint}")
92
137
  request_timeout = timeout || @timeout
93
138
 
@@ -99,7 +144,7 @@ module Bitfab
99
144
  http.open_timeout = request_timeout
100
145
  http.read_timeout = request_timeout
101
146
 
102
- req = Net::HTTP::Post.new(uri.path, headers)
147
+ req = Net::HTTPGenericRequest.new(method, true, true, uri.request_uri, headers)
103
148
  req["Content-Encoding"] = prepared.content_encoding if prepared.content_encoding
104
149
  req.body = prepared.body
105
150
 
@@ -128,18 +173,32 @@ module Bitfab
128
173
 
129
174
  # Queue an external span on this client's trace transport (fire-and-forget).
130
175
  def send_external_span(payload)
131
- ref = carrier_ref(payload)
132
- @trace_completion.record(ref.trace_id, ref.span_id) if ref&.span_id
133
- trace_transport&.submit(
134
- "external_span",
135
- payload.merge("sdkVersion" => VERSION),
136
- recorded_meta("external_span", payload, carrier_ref(payload))
137
- )
176
+ if (data = payload.dig("rawSpan", "span_data")).is_a?(Hash) && !data.key?("runtime")
177
+ payload = payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => data.merge("runtime" => ThreadPropagation.runtime)))
178
+ end
179
+ if payload["rawSpan"].is_a?(Hash) && !payload["rawSpan"].key?("span_origin")
180
+ payload = payload.merge("rawSpan" => payload["rawSpan"].merge("span_origin" => Bitfab.make_span_origin("span")))
181
+ end
182
+ submit = ->(record) {
183
+ ref = carrier_ref(record)
184
+ @trace_completion.record(ref.trace_id, ref.span_id) if ref&.span_id
185
+ trace_transport&.submit(
186
+ "external_span",
187
+ record.merge("sdkVersion" => VERSION),
188
+ recorded_meta("external_span", record, ref)
189
+ )
190
+ }
191
+ @simulation_plan ? @simulation_plan.send_span(payload, &submit) : submit.call(payload)
192
+ end
193
+
194
+ def send_internal_trace(function_id, payload)
195
+ record = payload.merge("functionId" => function_id, "sdkVersion" => VERSION)
196
+ trace_transport&.submit("internal_trace", record, recorded_meta("internal_trace", record, nil))
138
197
  end
139
198
 
140
199
  # Make a GET request to the Bitfab API.
141
200
  # Returns parsed JSON response hash.
142
- def get(endpoint, timeout: nil)
201
+ def get(endpoint, timeout: nil, connection_close: false)
143
202
  uri = URI("#{@service_url}#{endpoint}")
144
203
  request_timeout = timeout || @timeout
145
204
 
@@ -150,6 +209,7 @@ module Bitfab
150
209
 
151
210
  # request_uri (not path) so any query string on the endpoint survives.
152
211
  req = Net::HTTP::Get.new(uri.request_uri, headers)
212
+ req["Connection"] = "close" if connection_close
153
213
  response = http.request(req)
154
214
 
155
215
  unless response.is_a?(Net::HTTPSuccess)
@@ -189,7 +249,7 @@ module Bitfab
189
249
  # same org and trace function or the server rejects the replay
190
250
  def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
191
251
  code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_ids: nil,
192
- grader_ids: nil, db_branch_settings: nil)
252
+ grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, include_original_metadata: false)
193
253
  payload = {
194
254
  "traceFunctionKey" => trace_function_key
195
255
  }
@@ -211,6 +271,9 @@ module Bitfab
211
271
  end
212
272
  end
213
273
  payload["graderIds"] = grader_ids unless grader_ids.nil?
274
+ payload["attempts"] = attempts if attempts > 1
275
+ payload["onlyWithAssertions"] = true if only_with_assertions
276
+ payload["includeOriginalMetadata"] = true if include_original_metadata
214
277
  payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
215
278
  git_state = GitState.resolved
216
279
  payload["git"] = git_state if git_state
@@ -279,8 +342,9 @@ module Bitfab
279
342
  request("/api/sdk/replay/releaseDbBranchLease", {"neonBranchId" => neon_branch_id}, timeout: 30)
280
343
  end
281
344
 
282
- def resolve_db_branch_lease(test_run_id, trace_id, db_branch_settings = nil)
345
+ def resolve_db_branch_lease(test_run_id, trace_id, db_branch_settings = nil, attempt = 0)
283
346
  payload = {"testRunId" => test_run_id, "traceId" => trace_id}
347
+ payload["attempt"] = attempt if attempt.positive?
284
348
  payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
285
349
  request(
286
350
  "/api/sdk/replay/resolveDbBranchLease",
@@ -291,15 +355,18 @@ module Bitfab
291
355
 
292
356
  # Queue an external trace on this client's trace transport (fire-and-forget).
293
357
  def send_external_trace(payload)
294
- ref = carrier_ref(payload)
295
- if payload["completed"] == true && ref
296
- @trace_completion.close(ref.trace_id, dropped: payload["dropped"] == true) do |count|
297
- submit_external_trace(payload.merge("expectedSpanCount" => count))
358
+ submit = ->(record) {
359
+ ref = carrier_ref(record)
360
+ if record["completed"] == true && ref
361
+ @trace_completion.close(ref.trace_id, dropped: record["dropped"] == true) do |count|
362
+ submit_external_trace(record.merge("expectedSpanCount" => count))
363
+ end
364
+ else
365
+ @trace_completion.open(ref.trace_id) if ref
366
+ submit_external_trace(record)
298
367
  end
299
- else
300
- @trace_completion.open(ref.trace_id) if ref
301
- submit_external_trace(payload)
302
- end
368
+ }
369
+ @simulation_plan ? @simulation_plan.send_trace(payload, &submit) : submit.call(payload)
303
370
  end
304
371
 
305
372
  def submit_external_trace(payload)
@@ -647,11 +714,15 @@ module Bitfab
647
714
  # Wait for queued spans and trace completions to reach the server, within
648
715
  # one total deadline. Returns true when everything landed in time.
649
716
  def flush_traces(timeout: 30)
650
- Transport.flush_trace_transports([timeout, 0].max)
717
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [timeout, 0].max
718
+ released = SimulationPlan.release_all([timeout / 2.0, SimulationPlan::READ_TIMEOUT].min)
719
+ flushed = Transport.flush_trace_transports([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
720
+ released && flushed
651
721
  end
652
722
  end
653
723
 
654
724
  at_exit do
655
- Transport.shutdown_trace_transports(4)
725
+ SimulationPlan.release_all(2, stop: true)
726
+ Transport.shutdown_trace_transports(2)
656
727
  end
657
728
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Bitfab
6
+ class Labels
7
+ LABELS_PATH = "/api/sdk/traces/labels"
8
+ HUMAN_LABELS_PATH = "#{LABELS_PATH}/human"
9
+
10
+ def initialize(http_client)
11
+ @http_client = http_client
12
+ end
13
+
14
+ def save(label:, annotation:, trace_id: nil, original_trace_id: nil, attempt: nil,
15
+ confidence: nil, test_run_id: nil, assertion_id: nil)
16
+ update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
17
+ update["label"] = label
18
+ update["annotation"] = annotation
19
+ update["confidence"] = confidence unless confidence.nil?
20
+ save_all([update], test_run_id:).first
21
+ end
22
+
23
+ def save_all(labels, test_run_id: nil)
24
+ payload = {"labels" => labels}
25
+ payload["testRunId"] = test_run_id unless test_run_id.nil?
26
+ @http_client.request(LABELS_PATH, payload)["labels"]
27
+ end
28
+
29
+ def skip(trace_id: nil, original_trace_id: nil, attempt: nil, test_run_id: nil, assertion_id: nil)
30
+ update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
31
+ save_all([update.merge("skip" => true)], test_run_id:).first
32
+ end
33
+
34
+ def archive(trace_id: nil, original_trace_id: nil, attempt: nil, test_run_id: nil, assertion_id: nil)
35
+ update = label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
36
+ save_all([update.merge("archive" => true)], test_run_id:).first
37
+ end
38
+
39
+ def save_human(label:, annotation:, trace_id:, confidence: nil, assertion_id: nil)
40
+ update = {"traceId" => trace_id, "label" => label, "annotation" => annotation}
41
+ update["assertionId"] = assertion_id unless assertion_id.nil?
42
+ update["confidence"] = confidence unless confidence.nil?
43
+ save_human_all([update]).first
44
+ end
45
+
46
+ def save_human_all(labels)
47
+ @http_client.request(HUMAN_LABELS_PATH, {"labels" => labels})["labels"]
48
+ end
49
+
50
+ def get(trace_id)
51
+ get_all([trace_id]).first
52
+ end
53
+
54
+ def get_all(trace_ids)
55
+ return [] if trace_ids.empty?
56
+
57
+ query = URI.encode_www_form("traceIds" => trace_ids.join(","))
58
+ @http_client.get("#{LABELS_PATH}?#{query}")["labels"]
59
+ end
60
+
61
+ private
62
+
63
+ def label_target(trace_id:, original_trace_id:, attempt:, assertion_id:)
64
+ if trace_id.nil? == original_trace_id.nil?
65
+ raise ArgumentError, "Pass exactly one of trace_id or original_trace_id. " \
66
+ "Use original_trace_id for a replay verdict, with the test_run_id it ran under."
67
+ end
68
+
69
+ update = {}
70
+ update["assertionId"] = assertion_id unless assertion_id.nil?
71
+ if trace_id.nil?
72
+ update["originalTraceId"] = original_trace_id
73
+ update["attempt"] = attempt unless attempt.nil?
74
+ else
75
+ update["traceId"] = trace_id
76
+ end
77
+ update
78
+ end
79
+ end
80
+ end
data/lib/bitfab/replay.rb CHANGED
@@ -75,9 +75,10 @@ module Bitfab
75
75
  # thread. Child threads and processes intentionally do not inherit it.
76
76
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
77
77
  mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
78
- db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil)
78
+ db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil, replay_attempt: nil)
79
79
  previous = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
80
80
  ctx = {
81
+ replay_attempt:,
81
82
  test_run_id:,
82
83
  input_source_span_id:,
83
84
  input_source_trace_id:,
@@ -256,12 +257,27 @@ module Bitfab
256
257
  # A raising callback never crashes the run.
257
258
  # @return [Hash] with :items, :test_run_id, :test_run_url
258
259
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
259
- max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
260
- dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked",
260
+ concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
261
+ dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, dry_run: false,
261
262
  adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil)
263
+ concurrency_options = ReplayConcurrency.resolve(concurrency, attempts, max_concurrency)
264
+ attempts = concurrency_options.attempts
265
+ max_concurrency = concurrency_options.max_concurrency
266
+ process_executor = (concurrency_options.primitive == "process") ? ReplayProcesses.current : nil
267
+ if concurrency_options.primitive == "process" && process_executor.nil?
268
+ raise ArgumentError, "Process replay requires bitfab-replay --registry so each child can load the application"
269
+ end
262
270
  unless MOCK_STRATEGIES.include?(mock.to_s)
263
271
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
264
272
  end
273
+ unless max_concurrency.nil?
274
+ unless max_concurrency.is_a?(Integer) && max_concurrency.positive?
275
+ raise ArgumentError, "max_concurrency must be a positive integer or nil for unlimited concurrency."
276
+ end
277
+ end
278
+ unless attempts.is_a?(Integer) && attempts.between?(1, 100)
279
+ raise ArgumentError, "attempts must be an integer from 1 to 100."
280
+ end
265
281
  item_finish_callback = on_item_finish || on_progress
266
282
  if trace_ids
267
283
  raise ArgumentError, "trace_ids must contain at least one trace ID." if trace_ids.empty?
@@ -281,7 +297,7 @@ module Bitfab
281
297
  # function, recorded under another). Only fires when the method's key is
282
298
  # introspectable; an untraced method falls through to the persistence
283
299
  # check in complete_replay. Mirrors the TypeScript/Python SDKs.
284
- declared_key = Traceable.trace_function_key_for(receiver, method_name)
300
+ declared_key = method_name && Traceable.trace_function_key_for(receiver, method_name)
285
301
  if declared_key && declared_key != trace_function_key
286
302
  raise ArgumentError,
287
303
  "Method #{method_name} is traced under trace function key '#{declared_key}' but replay was " \
@@ -289,6 +305,13 @@ module Bitfab
289
305
  "or point at the method traced under '#{trace_function_key}'."
290
306
  end
291
307
 
308
+ raise ArgumentError, "Replay requires an API key." unless client.send(:resolve_api_key)
309
+ unless declared_key
310
+ callable = method_name ? receiver.method(method_name) : receiver
311
+ receiver = ManagedCallable.new(client, trace_function_key, callable)
312
+ method_name = :call
313
+ end
314
+
292
315
  http_client = client.instance_variable_get(:@http_client)
293
316
 
294
317
  # Resolved override list: per-call overrides FIRST (they win), then the
@@ -302,7 +325,7 @@ module Bitfab
302
325
  # the count), so it's omitted from the request entirely.
303
326
  effective_limit = trace_ids ? nil : (limit || 5)
304
327
 
305
- include_db_branch_lease = db_branch_enabled?(db_branch)
328
+ include_db_branch_lease = db_branch_enabled?(db_branch) && !dry_run
306
329
 
307
330
  # code_change_files controls capture: omitted auto-captures, an array
308
331
  # wins, and explicit nil opts out. Preserve a caller-supplied description
@@ -329,22 +352,45 @@ module Bitfab
329
352
  include_db_branch_lease:,
330
353
  dataset_ids: resolved_dataset_ids,
331
354
  grader_ids:,
355
+ attempts:,
356
+ only_with_assertions:,
357
+ include_original_metadata: !adapt_inputs.nil?,
332
358
  db_branch_settings: resolved_db_branch_settings
333
359
  )
334
360
  test_run_id = replay_data["testRunId"]
335
361
  test_run_url = replay_data["testRunUrl"]
336
362
  full_test_run_url = "#{client.service_url}#{test_run_url}"
337
- server_items = replay_data["items"] || []
363
+ server_items = attempts.times.flat_map do |attempt|
364
+ (replay_data["items"] || []).map do |source|
365
+ item = source.merge("attempt" => attempt)
366
+ if attempt.positive?
367
+ item.delete("dbBranchLease")
368
+ item.delete("dbBranchLeaseError")
369
+ end
370
+ item
371
+ end
372
+ end
338
373
 
339
374
  result_items = if server_items.any?
340
375
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
341
376
  adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
342
377
  on_item_finish: item_finish_callback,
343
- mock_overrides: resolved_overrides)
378
+ mock_overrides: resolved_overrides, dry_run:, process_executor:)
344
379
  else
345
380
  []
346
381
  end
347
382
 
383
+ if dry_run
384
+ begin
385
+ http_client.complete_replay(test_run_id)
386
+ rescue => e
387
+ warn "Bitfab: could not finalize dry run: #{e.message}"
388
+ end
389
+ result = {items: public_replay_items(result_items), test_run_id:, test_run_url: full_test_run_url, attempts:}
390
+ write_replay_result_file(result)
391
+ return result
392
+ end
393
+
348
394
  # Spans and completions ride a batched transport, so the run is only safe
349
395
  # to finalize once the server confirms every replay trace it queued: the
350
396
  # trace-ID mapping complete_replay builds would otherwise race the
@@ -440,6 +486,7 @@ module Bitfab
440
486
  result_items.each { |item| item.delete(:_sdk_trace_id) }
441
487
 
442
488
  result = {
489
+ attempts:,
443
490
  items: result_items,
444
491
  test_run_id:,
445
492
  test_run_url: full_test_run_url
@@ -698,7 +745,7 @@ module Bitfab
698
745
  # Process all replay items, optionally in parallel using threads.
699
746
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
700
747
  adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil,
701
- mock_overrides: [])
748
+ mock_overrides: [], dry_run: false, process_executor: nil)
702
749
  concurrency = max_concurrency || server_items.length
703
750
 
704
751
  # Lifecycle callbacks run from worker threads in the parallel path, so the
@@ -719,7 +766,7 @@ module Bitfab
719
766
  # surfaced. original_trace_id (the historical trace being replayed, taken
720
767
  # from the server item) is what a UI keys on to identify what just finished.
721
768
  # source_trace_id/source_span_id are kept as deprecated aliases.
722
- report_start = lambda do |original_trace_id, original_span_id|
769
+ report_start = lambda do |original_trace_id, original_span_id, attempt|
723
770
  progress_mutex.synchronize do
724
771
  started += 1
725
772
  next unless on_item_start
@@ -728,6 +775,7 @@ module Bitfab
728
775
  on_item_start.call({
729
776
  type: "started", test_run_id:, started:, completed:, total:, succeeded:, errored:,
730
777
  item: {
778
+ attempt:,
731
779
  original_trace_id:,
732
780
  original_span_id:,
733
781
  source_trace_id: original_trace_id,
@@ -759,7 +807,7 @@ module Bitfab
759
807
  # Deliver this item's trace now, then read its server id back off the
760
808
  # ingest response. A flush failure never crashes the run: the id stays
761
809
  # nil and the end-of-run barrier remains the authority on persistence.
762
- flush_finished_item_trace.call
810
+ flush_finished_item_trace.call unless dry_run
763
811
  result[:trace_id] = http_client.peek_server_trace_id(result[:_sdk_trace_id]) || result[:trace_id]
764
812
  progress_mutex.synchronize do
765
813
  completed += 1
@@ -771,6 +819,8 @@ module Bitfab
771
819
  on_item_finish.call({
772
820
  test_run_id:, completed:, total:, succeeded:, errored:,
773
821
  item: {
822
+ attempt: result[:attempt],
823
+ ingestion_type: result[:ingestion_type],
774
824
  trace_id: result[:trace_id],
775
825
  original_trace_id:,
776
826
  original_span_id:,
@@ -801,11 +851,19 @@ module Bitfab
801
851
  end
802
852
  end
803
853
 
854
+ execute = lambda do |item|
855
+ if process_executor
856
+ process_executor.call(item, test_run_id, mock_strategy, include_db_branch_lease, db_branch_settings, dry_run:)
857
+ else
858
+ process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
859
+ adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:)
860
+ end
861
+ end
862
+
804
863
  if concurrency <= 1
805
864
  server_items.map do |item|
806
- report_start.call(original_trace_id_of(item), original_span_id_of(item))
807
- result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
808
- adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
865
+ report_start.call(original_trace_id_of(item), original_span_id_of(item), item["attempt"] || 0)
866
+ result = execute.call(item)
809
867
  report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
810
868
  result
811
869
  end
@@ -821,9 +879,8 @@ module Bitfab
821
879
  item, idx = work_mutex.synchronize { work_queue.shift }
822
880
  break unless item
823
881
 
824
- report_start.call(original_trace_id_of(item), original_span_id_of(item))
825
- result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
826
- adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:)
882
+ report_start.call(original_trace_id_of(item), original_span_id_of(item), item["attempt"] || 0)
883
+ result = execute.call(item)
827
884
  results_mutex.synchronize { results[idx] = result }
828
885
  report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
829
886
  end
@@ -842,8 +899,9 @@ module Bitfab
842
899
  # than propagated, so one bad trace never aborts the whole replay run
843
900
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
844
901
  def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy,
845
- adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [])
902
+ adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false)
846
903
  metrics = extract_server_item_metrics(server_item)
904
+ attempt = server_item["attempt"] || 0
847
905
  # The ORIGINAL (historical) trace/span this item replays. Canonical
848
906
  # keys are originalTraceId/originalSpanId; older servers send them under
849
907
  # the deprecated sourceTraceId/sourceSpanId aliases (see *_of helpers).
@@ -866,7 +924,7 @@ module Bitfab
866
924
  db_branch_timings = include_db_branch_lease ? server_item["dbBranchTimings"] : nil
867
925
  if include_db_branch_lease && lease.nil? && lease_error.nil?
868
926
  begin
869
- resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings)
927
+ resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings, attempt)
870
928
  rescue => cause
871
929
  error = DbBranchReplayError.new(
872
930
  "lease_request_failed",
@@ -905,7 +963,7 @@ module Bitfab
905
963
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
906
964
  include_outputs = mock_strategy == "all"
907
965
  mock_tree = nil
908
- if mock_strategy == "all" || mock_strategy == "marked" || overrides_present
966
+ if !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
909
967
  begin
910
968
  tree = http_client.get_span_tree(
911
969
  original_span_id,
@@ -930,6 +988,7 @@ module Bitfab
930
988
  fetch_span_output = (mock_tree && !include_outputs) ? build_span_output_fetcher(http_client) : nil
931
989
 
932
990
  adapt_ctx = {
991
+ metadata: server_item["originalMetadata"].is_a?(Hash) ? server_item["originalMetadata"].dup : {},
933
992
  original_trace_id:,
934
993
  original_span_id:,
935
994
  # Deprecated aliases for original_trace_id/original_span_id.
@@ -951,6 +1010,8 @@ module Bitfab
951
1010
  fetch_span_output:,
952
1011
  adapt_inputs:,
953
1012
  adapt_ctx:,
1013
+ attempt:,
1014
+ dry_run:,
954
1015
  db_branch_lease: lease,
955
1016
  db_branch_timings:,
956
1017
  source_bitfab_trace_id: original_trace_id,
@@ -960,6 +1021,8 @@ module Bitfab
960
1021
  rescue => e
961
1022
  warn "Bitfab: replay item for span #{original_span_id} failed before execution: #{e.message}"
962
1023
  {
1024
+ attempt:,
1025
+ ingestion_type: server_item["ingestionType"],
963
1026
  input: [],
964
1027
  result: nil,
965
1028
  original_output: nil,
@@ -1103,6 +1166,7 @@ module Bitfab
1103
1166
  original_duration_ms: server_item["originalDurationMs"] || server_item["durationMs"],
1104
1167
  original_tokens: server_item["originalTokens"] || server_item["tokens"],
1105
1168
  original_model: server_item["originalModel"] || server_item["model"],
1169
+ ingestion_type: server_item["ingestionType"],
1106
1170
  tokens: nil
1107
1171
  }
1108
1172
  end
@@ -1149,6 +1213,7 @@ module Bitfab
1149
1213
  status = http_client.get_replay_status(test_run_id, expected_span_counts)
1150
1214
  ready = status["traceIds"]
1151
1215
  ready = {} unless ready.is_a?(Hash)
1216
+ read_back_trace_ids.merge!(ready)
1152
1217
  missing = expected_span_counts.keys - ready.keys
1153
1218
  return read_back_trace_ids if missing.empty?
1154
1219
  break if Otel.monotonic_now >= deadline
@@ -1185,7 +1250,7 @@ module Bitfab
1185
1250
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
1186
1251
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1187
1252
  fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, db_branch_timings: nil,
1188
- source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil)
1253
+ source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false)
1189
1254
  args, kwargs = Serialize.deserialize_inputs(item)
1190
1255
 
1191
1256
  fn_result = nil
@@ -1201,10 +1266,11 @@ module Bitfab
1201
1266
  # Declared before this item's first span is submitted: the transport
1202
1267
  # records delivery only for traces someone asked about, so anything
1203
1268
  # submitted before this would go untracked.
1204
- http_client&.track_trace_deliveries([sdk_trace_id])
1269
+ http_client&.track_trace_deliveries([sdk_trace_id]) unless dry_run
1205
1270
 
1206
1271
  begin
1207
1272
  ReplayContext.with_context(
1273
+ replay_attempt: attempt,
1208
1274
  test_run_id:,
1209
1275
  input_source_span_id:,
1210
1276
  input_source_trace_id:,
@@ -1231,7 +1297,7 @@ module Bitfab
1231
1297
  rescue => e
1232
1298
  replay_error = e
1233
1299
  end
1234
- if replay_error.nil?
1300
+ if replay_error.nil? && !dry_run
1235
1301
  begin
1236
1302
  replay_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1237
1303
  fn_result = if kwargs.empty?
@@ -1239,6 +1305,7 @@ module Bitfab
1239
1305
  else
1240
1306
  receiver.send(method_name, *args, **kwargs)
1241
1307
  end
1308
+ fn_result = fn_result.to_a if fn_result.is_a?(Enumerator)
1242
1309
  replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1243
1310
  rescue => e
1244
1311
  # The method ran and raised, so it still has a duration.
@@ -1258,7 +1325,9 @@ module Bitfab
1258
1325
 
1259
1326
  item_error = fn_error || replay_error
1260
1327
  {
1261
- input: args,
1328
+ attempt:,
1329
+ ingestion_type: metrics[:ingestion_type],
1330
+ input: kwargs.empty? ? args : [*args, kwargs],
1262
1331
  result: fn_result,
1263
1332
  original_output: item["output"],
1264
1333
  error: item_error&.message,
@@ -14,7 +14,13 @@ module Bitfab
14
14
  registry_path, replay_argv = parse(argv)
15
15
  loader = registry_loader || method(:load_registry)
16
16
  registry = loader.call(registry_path)
17
- ReplayCli.run(registry, argv: replay_argv, stdout:, stderr:)
17
+ if replay_argv.first == "--execute-item"
18
+ raise ArgumentError, "Expected one process assignment path" unless replay_argv.length == 2
19
+ return ReplayProcesses.execute_assignment(registry, replay_argv[1], stderr:)
20
+ end
21
+ ReplayProcesses.with_registry(registry_path, replay_argv, stderr:) do
22
+ ReplayCli.run(registry, argv: replay_argv, stdout:, stderr:)
23
+ end
18
24
  end
19
25
 
20
26
  def main(argv: ARGV, stdout: $stdout, stderr: $stderr)