bitfab 0.57.7 → 0.58.0

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: b92a57be2a0c05885f0f84f757ede302ab14aed53ad422c85ee2781591a39786
4
- data.tar.gz: ddb9b050e2f66e9591c17be161c2ebd25d395b625704baee80e396801f5a9e25
3
+ metadata.gz: 49c5627d0b0880adf5971614d2b017bf97a1886313776b757996d1bdab1d1f92
4
+ data.tar.gz: cce67f5a022711dc884d9b391d2daa4547b5fc11fdff1ab159c7a5b82fb404bf
5
5
  SHA512:
6
- metadata.gz: 0542a3b4dcb64f183ce35f7c9f16ead26e858fa1fefb7889115842e552b4855842ba175bea19893912ec37547708403d115259a201b2e02a1e6cb5ffa84f79f6
7
- data.tar.gz: a530cd08bc1cdbc42467ea640c8f64e3035569c6723032626284bd6397744a21f5149ef0bc2d32af50aab1b1bbb856e8ba7510d1eb3640824de4f6f4e4472c43
6
+ metadata.gz: 28f813f00802eda3f55615bf5acba0264d6e9a80130117522436eefb943be3fbd17753289a6a2f7e6295ed9772380221a89a9c3e8477041b974f5d3f4cde917f
7
+ data.tar.gz: a15a81f27f2ef548b854a7b53cdd32a813583b31066a0cba8a40aa364c1bdf9e771f9534bd0aab8b5355bc146fbff18ec0a1cfb9a417634e28ab9691e6cdc6c7
data/README.md CHANGED
@@ -235,7 +235,7 @@ class OrderPipeline
235
235
  end
236
236
  ```
237
237
 
238
- Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting a span limit stops new capture but allows already-open spans in every Fiber to finish. `max_depth:` (default `30`), `max_captured_subtree_spans:` (default `512`), `max_spans:` (default `2048`), `exclude:`, and `include_wrappers:` control the subtree. `max_spans:` is a strict cap on the total number of spans one trace root sends. It counts the root span itself, discovered calls, `bitfab_node` calls, nested trace roots, and spans whose capture is on or off, including spans sent without inputs and outputs because the sim plan could not be read. Nothing past it is sent. It must be at least 1, and a lower value raises `ArgumentError`. `max_captured_subtree_spans:` caps discovered calls sent with their inputs and outputs. It does not count `bitfab_node` calls, nested trace roots, or spans the loaded sim plan turned capture off for, so those keep recording past it up to `max_spans:`. Spans sent without inputs and outputs only because the sim plan could not be read still count toward it. Past it, further discovered calls with capture on record nothing. It is never higher than `max_spans:`. Once the plan has loaded, the SDK never builds the inputs or outputs of a span whose capture it turned off. The client starts reading the sim plan when it is built. A `bitfab_trace` root that starts while that first read is still running waits for it, for at most five seconds. Once the first read has finished, whether it succeeded or failed, no later root waits. Nested roots and the calls beneath a root never wait, and nothing waits when the plan is disabled. If that first read fails, spans other than each trace's root span are sent without inputs and outputs until a read succeeds, and they still count toward both limits. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_node` when replay must substitute it. The configured node records once in each enclosing trace. Mixing explicit spans with subtree tracing raises `Bitfab::MixedTracingError`.
238
+ Capture uses `TracePoint` only during the root call. It skips Ruby/runtime code, dependency gems, Bitfab SDK internals, blocks, lambdas, and forwarding wrappers. When the root belongs to an installed application gem, that gem is the first-party boundary; neighboring dependency gems remain excluded. It follows recursion, modules, inheritance, `define_method`, and same-thread child Fibers; aliases are recorded and excluded by the name that was invoked. Child Fibers created during capture inherit node policies, trace identity, parenting, and depth limits while retaining separate call stacks. Pre-existing Fibers, `Fiber.new(storage: nil)`, and child threads do not inherit subtree context. When an inner capture ends, child Fibers retain any still-active outer capture; inherited context expires once all enclosing captures end. While a root remains active, hitting a span limit stops new capture but allows already-open spans in every Fiber to finish. `max_depth:` (default `30`), `max_captured_subtree_spans:` (default `500`), `exclude:`, and `include_wrappers:` control the subtree. There is no cap on the total number of spans one trace root sends. `max_spans:` was removed, and passing it raises `ArgumentError`. `max_captured_subtree_spans:` caps discovered calls sent with their inputs and outputs. It does not count `bitfab_node` calls, nested trace roots, or spans the loaded sim plan turned capture off for, so those keep recording past it. Spans sent without inputs and outputs only because the sim plan could not be read still count toward it. Past it, further discovered calls with capture on record nothing. Once the plan has loaded, the SDK does not send a span whose capture it turned off, and that span's children move up to the nearest visible parent. The trace's root span, a framework integration span, and a span that recorded an error are still sent, and the SDK never builds their inputs or outputs. A `bitfab_node` call and a nested trace root ignore the plan and always keep their inputs and outputs. The client starts reading the sim plan when it is built. A `bitfab_trace` root that starts while that first read is still running waits for it, for at most five seconds. Once the first read has finished, whether it succeeded or failed, no later root waits. Nested roots and the calls beneath a root never wait, and nothing waits when the plan is disabled. If that first read fails, spans other than each trace's root span, `bitfab_node` calls, and nested trace roots are sent without inputs and outputs until a read succeeds, and they still count toward `max_captured_subtree_spans:`. Automatically captured calls are not replay mock targets. Declare a call with `bitfab_node` when replay must substitute it. The configured node records once in each enclosing trace. Mixing explicit spans with subtree tracing raises `Bitfab::MixedTracingError`.
239
239
 
240
240
  When capture ends, already-open automatic calls in suspended Fibers are emitted once with a nil output and `Subtree capture ended before the call returned` as the error, unless a real error was already observed. Their inputs and parenting are retained. The SDK does not resume Fibers, and later resumption cannot add spans to the ended capture. If hot reload moves the root to an unresolved source, the last valid first-party boundary and its exclusions remain in use.
241
241
 
data/lib/bitfab/client.rb CHANGED
@@ -376,7 +376,7 @@ module Bitfab
376
376
  capture_when: "always", mock_on_replay: false, independent_root: false,
377
377
  explicit_span_receiver: nil, explicit_span_method_name: nil, test_run_id: nil, finalize: nil,
378
378
  surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
379
- result_recorded_elsewhere: false)
379
+ result_recorded_elsewhere: false, declared_node: false)
380
380
  return yield unless should_record?
381
381
 
382
382
  @http_client.refresh_simulation_plan
@@ -477,7 +477,8 @@ module Bitfab
477
477
  test_run_id: resolved_test_run_id,
478
478
  input_source_span_id: resolved_input_source_span_id,
479
479
  mock_source:,
480
- instrumentation: (surface == :trace) ? "trace" : "span"
480
+ instrumentation: (surface == :trace) ? "trace" : "span",
481
+ declared_node:
481
482
  )
482
483
  on_complete&.call(result: mocked_output, error: nil, ended_at: Bitfab.now_iso_timestamp)
483
484
  return mocked_output
@@ -509,6 +510,8 @@ module Bitfab
509
510
  @http_client.trace_completion.start(trace_id, span_id)
510
511
  recording_override = managed_root ? {} : nil
511
512
  content_withheld = lambda do
513
+ next false if declared_node
514
+
512
515
  instrumentation = recording_override&.dig(:instrumentation) || ((surface == :trace) ? "trace" : "span")
513
516
  content_off_by_simulation_plan?(
514
517
  trace_id:, trace_function_key:, span_name: recording_override&.dig(:name) || span_name, instrumentation:
@@ -544,6 +547,7 @@ module Bitfab
544
547
  send_span(
545
548
  span_data: content_off ? span_data.merge("content_off_by_simulation_plan" => true) : span_data,
546
549
  instrumentation: recorded_instrumentation,
550
+ declared_node:,
547
551
  trace_function_key:,
548
552
  trace_id:,
549
553
  span_id:,
@@ -921,7 +925,7 @@ module Bitfab
921
925
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
922
926
  span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
923
927
  started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil, mocked: false,
924
- mock_target: nil, mock_source: nil, span_data: {}, instrumentation: "span")
928
+ mock_target: nil, mock_source: nil, span_data: {}, instrumentation: "span", declared_node: false)
925
929
  extra_span_data = span_data
926
930
  # If drop() was called on this trace, suppress the span PAYLOAD upload for
927
931
  # every span that completes after the flag was set. The trace completion
@@ -933,9 +937,9 @@ module Bitfab
933
937
  # Human-readable JSON (input/output fields). The *_with_report variants
934
938
  # also report what could not be faithfully captured so a lossy span is
935
939
  # marked non-replayable below instead of shipping as if it round-trips.
936
- content_off = extra_span_data["content_off_by_simulation_plan"] == true ||
940
+ content_off = !declared_node && (extra_span_data["content_off_by_simulation_plan"] == true ||
937
941
  content_off_by_simulation_plan?(trace_id:, trace_function_key:, span_name:, instrumentation:) ||
938
- (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation:))
942
+ (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation:)))
939
943
  if content_off
940
944
  dropped = []
941
945
  span_data = {"name" => span_name, "type" => span_type, "function_name" => function_name}
@@ -1011,7 +1015,7 @@ module Bitfab
1011
1015
  }]
1012
1016
  end
1013
1017
 
1014
- @http_client.send_external_span(payload) # Returns the background thread
1018
+ @http_client.send_external_span(payload, declared_node)
1015
1019
  end
1016
1020
 
1017
1021
  # Advance the per-(key, name) call counter when this invocation is a
@@ -1141,7 +1145,7 @@ module Bitfab
1141
1145
  # output as the result and no error.
1142
1146
  def send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
1143
1147
  span_name:, span_type:, function_name:, args:, kwargs:, mocked_output:,
1144
- started_at:, test_run_id:, input_source_span_id:, mock_source:, instrumentation: "span")
1148
+ started_at:, test_run_id:, input_source_span_id:, mock_source:, instrumentation: "span", declared_node: false)
1145
1149
  ended_at = Bitfab.now_iso_timestamp
1146
1150
  send_span(
1147
1151
  trace_function_key:,
@@ -1163,6 +1167,7 @@ module Bitfab
1163
1167
  input_source_span_id:,
1164
1168
  mocked: true,
1165
1169
  instrumentation:,
1170
+ declared_node:,
1166
1171
  # Both paths short-circuit the call, so the target is always the
1167
1172
  # output; only where the value came from differs.
1168
1173
  mock_target: "output",
@@ -1223,7 +1228,7 @@ module Bitfab
1223
1228
 
1224
1229
  # Experimentally trace an existing method and its first-party call subtree.
1225
1230
  def trace(klass, method_name, name: nil, type: "custom",
1226
- max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
1231
+ max_depth: Subtree::DEFAULT_MAX_DEPTH,
1227
1232
  max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
1228
1233
  exclude: [], include_wrappers: false, mock_on_replay_default: false)
1229
1234
  Bitfab::Traceable.trace(
@@ -1232,7 +1237,6 @@ module Bitfab
1232
1237
  name:,
1233
1238
  type:,
1234
1239
  max_depth:,
1235
- max_spans:,
1236
1240
  max_captured_subtree_spans:,
1237
1241
  exclude:,
1238
1242
  include_wrappers:,
@@ -188,7 +188,7 @@ module Bitfab
188
188
  end
189
189
 
190
190
  # Queue an external span on this client's trace transport (fire-and-forget).
191
- def send_external_span(payload)
191
+ def send_external_span(payload, declared_node = false)
192
192
  if (data = payload.dig("rawSpan", "span_data")).is_a?(Hash) && !data.key?("runtime")
193
193
  payload = payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => data.merge("runtime" => ThreadPropagation.runtime)))
194
194
  end
@@ -204,7 +204,11 @@ module Bitfab
204
204
  recorded_meta("external_span", record, ref)
205
205
  )
206
206
  }
207
- @simulation_plan ? @simulation_plan.send_span(payload, &submit) : submit.call(payload)
207
+ discard = ->(record) {
208
+ ref = carrier_ref(record)
209
+ @trace_completion.discard(ref.trace_id, ref.span_id) if ref&.span_id
210
+ }
211
+ @simulation_plan ? @simulation_plan.send_span(payload, declared_node:, discard:, &submit) : submit.call(payload)
208
212
  end
209
213
 
210
214
  def send_internal_trace(function_id, payload)
data/lib/bitfab/otel.rb CHANGED
@@ -24,7 +24,7 @@ module Bitfab
24
24
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
25
25
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
26
26
  MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
27
- MAX_QUEUE_SIZE = 8_192
27
+ MAX_QUEUE_SIZE = 16_384
28
28
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
29
29
  DIRECT_MAX_REQUEST_BATCH_SIZE = 128
30
30
  DEFAULT_EXPORT_CONCURRENCY = 32
@@ -331,17 +331,17 @@ module Bitfab
331
331
  if e.oversized
332
332
  warn_oversized(batch.spans.size)
333
333
  else
334
- Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
334
+ Bitfab.warn_delivery_problem("failed to export an OpenTelemetry span batch: #{e.message}")
335
335
  end
336
336
  false
337
337
  rescue => e
338
- Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
338
+ Bitfab.warn_delivery_problem("failed to export an OpenTelemetry span batch: #{e.message}")
339
339
  false
340
340
  end
341
341
 
342
342
  def warn_oversized(span_count)
343
343
  subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
344
- Bitfab.warn_always("#{subject} exceeded the ingestion request limit and could not be exported")
344
+ Bitfab.warn_delivery_problem("#{subject} exceeded the ingestion request limit and could not be exported")
345
345
  end
346
346
 
347
347
  def send_with_retries(request)
data/lib/bitfab/replay.rb CHANGED
@@ -1179,7 +1179,8 @@ module Bitfab
1179
1179
  # repeat itself. Acks cannot settle a request that timed out client-side
1180
1180
  # after the server committed, so anything short of fully delivered falls
1181
1181
  # through to the server poll, which stays the authority there.
1182
- def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids)
1182
+ def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids, timeout: nil)
1183
+ timeout ||= PERSISTENCE_TIMEOUT_SECONDS
1183
1184
  trace_ids = sdk_trace_ids.compact
1184
1185
 
1185
1186
  # Checked before flushing: a run whose traces never closed submitted
@@ -1194,7 +1195,7 @@ module Bitfab
1194
1195
  # timeout can fire while requests are still in flight and the server goes
1195
1196
  # on to persist every one of them. Failing here failed runs whose data had
1196
1197
  # fully landed.
1197
- flushed = Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS)
1198
+ flushed = Bitfab.flush_traces(timeout:)
1198
1199
 
1199
1200
  raw_deliveries = http_client.take_trace_deliveries(trace_ids)
1200
1201
  # Server-assigned traces.id per replay trace, read back off the ingest
@@ -1206,7 +1207,7 @@ module Bitfab
1206
1207
  expected_span_counts = deliveries.transform_values(&:span_count)
1207
1208
  return read_back_trace_ids if expected_span_counts.empty? || deliveries.each_value.all?(&:delivered)
1208
1209
 
1209
- deadline = Otel.monotonic_now + PERSISTENCE_TIMEOUT_SECONDS
1210
+ deadline = Otel.monotonic_now + timeout
1210
1211
  missing = expected_span_counts.keys
1211
1212
 
1212
1213
  while missing.any?
@@ -3,9 +3,9 @@
3
3
  module Bitfab
4
4
  class ReplayConcurrency
5
5
  UNSET = Object.new.freeze
6
- attr_reader :attempts, :max_concurrency, :primitive, :memory_throttle, :on_item_finish_in_child_process, :child_timeout
6
+ attr_reader :attempts, :max_concurrency, :primitive, :memory_throttle, :on_item_finish_in_child_process, :child_timeout, :child_delivery_timeout
7
7
 
8
- def initialize(attempts: 1, max_concurrency: UNSET, primitive: "async", memory_throttle: UNSET, on_item_finish_in_child_process: nil, child_timeout: 2400)
8
+ def initialize(attempts: 1, max_concurrency: UNSET, primitive: "async", memory_throttle: UNSET, on_item_finish_in_child_process: nil, child_timeout: 2400, child_delivery_timeout: UNSET)
9
9
  raise ArgumentError, "primitive must be async or process" unless %w[async process].include?(primitive)
10
10
  raise ArgumentError, "attempts must be an integer from 1 to 100" unless attempts.is_a?(Integer) && attempts.between?(1, 100)
11
11
  max_concurrency = (primitive == "process") ? 4 : 10 if max_concurrency.equal?(UNSET)
@@ -24,11 +24,31 @@ module Bitfab
24
24
  end
25
25
  raise ArgumentError, "child_timeout must be a positive finite number" unless child_timeout.is_a?(Numeric) && child_timeout.finite? && child_timeout.positive?
26
26
  @child_timeout = child_timeout
27
+ @child_delivery_timeout = resolve_child_delivery_timeout(child_delivery_timeout, primitive)
27
28
  @attempts, @max_concurrency, @primitive = attempts, max_concurrency, primitive
28
29
  @memory_throttle, @on_item_finish_in_child_process = memory_throttle, on_item_finish_in_child_process
29
30
  freeze
30
31
  end
31
32
 
33
+ private
34
+
35
+ def resolve_child_delivery_timeout(timeout, primitive)
36
+ if timeout.equal?(UNSET)
37
+ return (primitive == "process") ? Replay::PERSISTENCE_TIMEOUT_SECONDS : nil
38
+ end
39
+ return nil if timeout.nil? && primitive != "process"
40
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
41
+ raise ArgumentError, "child_delivery_timeout must be a positive number of seconds (got #{timeout.inspect})"
42
+ end
43
+ if primitive != "process"
44
+ raise ArgumentError, "child_delivery_timeout bounds how long a child process waits for its spans to reach Bitfab, " \
45
+ "and only primitive: process creates one. Under primitive: async the run's own delivery wait applies."
46
+ end
47
+ timeout
48
+ end
49
+
50
+ public
51
+
32
52
  def self.resolve(concurrency, attempts, max_concurrency)
33
53
  if concurrency
34
54
  raise ArgumentError, "concurrency must be a ReplayConcurrency" unless concurrency.is_a?(self)
@@ -42,37 +42,57 @@ module Bitfab
42
42
  end
43
43
  http = client.instance_variable_get(:@http_client)
44
44
  overrides = MockOverride.normalize(options[:mock_override]) + (client.instance_variable_get(:@mock_overrides) || [])
45
- result = Replay.process_single_item(http, assignment.fetch("item"), receiver, method_name,
46
- assignment.fetch("test_run_id"), assignment.fetch("mock"), options[:adapt_inputs],
47
- assignment.fetch("include_db_branch_lease"), assignment["db_branch_settings"],
48
- mock_overrides: overrides, dry_run: assignment.fetch("dry_run"))
49
- unless assignment["dry_run"]
50
- begin
51
- mapped = Replay.wait_for_replay_persistence(http, assignment.fetch("test_run_id"), [result[:_sdk_trace_id]])
52
- result[:trace_id] = mapped[result[:_sdk_trace_id]] || result[:trace_id]
53
- if result[:_sdk_trace_id] && result[:trace_id].nil?
54
- status = http.get_replay_status(assignment.fetch("test_run_id"), {result[:_sdk_trace_id] => 1})
55
- result[:trace_id] = status.fetch("traceIds", {})[result[:_sdk_trace_id]]
45
+ delivery_timeout = options[:concurrency]&.child_delivery_timeout || Replay::PERSISTENCE_TIMEOUT_SECONDS
46
+ result = nil
47
+ Bitfab.collect_delivery_problems do
48
+ result = Replay.process_single_item(http, assignment.fetch("item"), receiver, method_name,
49
+ assignment.fetch("test_run_id"), assignment.fetch("mock"), options[:adapt_inputs],
50
+ assignment.fetch("include_db_branch_lease"), assignment["db_branch_settings"],
51
+ mock_overrides: overrides, dry_run: assignment.fetch("dry_run"))
52
+ unless assignment["dry_run"]
53
+ begin
54
+ mapped = Replay.wait_for_replay_persistence(http, assignment.fetch("test_run_id"), [result[:_sdk_trace_id]], timeout: delivery_timeout)
55
+ result[:trace_id] = mapped[result[:_sdk_trace_id]] || result[:trace_id]
56
+ if result[:_sdk_trace_id] && result[:trace_id].nil?
57
+ status = http.get_replay_status(assignment.fetch("test_run_id"), {result[:_sdk_trace_id] => 1})
58
+ result[:trace_id] = status.fetch("traceIds", {})[result[:_sdk_trace_id]]
59
+ end
60
+ raise "Child replay trace persistence could not be confirmed" if result[:error].nil? && result[:trace_id].nil?
61
+ rescue => error
62
+ result[:replay_error] = error
63
+ result[:error] = error.message
56
64
  end
57
- raise "Child replay trace persistence could not be confirmed" if result[:error].nil? && result[:trace_id].nil?
58
- rescue => error
59
- result[:replay_error] = error
60
- result[:error] = error.message
61
65
  end
62
- end
63
- write_result(assignment.fetch("result_path"), result)
64
- hook = options[:concurrency]&.on_item_finish_in_child_process
65
- if hook && !assignment["dry_run"]
66
- begin
67
- hook.call({test_run_id: assignment.fetch("test_run_id"), item: Replay.public_replay_items([result]).first})
68
- rescue => error
69
- stderr.puts "[replay] child grading hook failed for #{result[:original_trace_id]} attempt #{result[:attempt]}: #{error}"
66
+ write_result(assignment.fetch("result_path"), result)
67
+ hook = options[:concurrency]&.on_item_finish_in_child_process
68
+ if hook && !assignment["dry_run"]
69
+ begin
70
+ hook.call({test_run_id: assignment.fetch("test_run_id"), item: Replay.public_replay_items([result]).first})
71
+ rescue => error
72
+ stderr.puts "[replay] child grading hook failed for #{result[:original_trace_id]} attempt #{result[:attempt]}: #{error}"
73
+ end
70
74
  end
75
+ unless client.close(timeout: delivery_timeout) || assignment["dry_run"]
76
+ Bitfab.count_delivery_problem("delivery was not confirmed within child_delivery_timeout (#{delivery_timeout}s). " \
77
+ "Spans that failed or were still sending when the child exited may never reach Bitfab.")
78
+ end
79
+ write_delivery_problems(assignment["delivery_problems_path"], result, Bitfab.delivery_problem_counts, stderr)
71
80
  end
72
- client.close(timeout: 5)
73
81
  result
74
82
  end
75
83
 
84
+ def write_delivery_problems(path, result, problems, stderr)
85
+ return if problems.empty?
86
+ label = "#{result[:original_trace_id]}##{result[:attempt]}"
87
+ report = problems.map do |message, count|
88
+ "[replay] span delivery problem for #{label}: #{message}#{" (x#{count})" if count > 1}\n"
89
+ end.join
90
+ stderr.write(report)
91
+ File.write(path, report, mode: "w", perm: 0o600) if path
92
+ rescue SystemCallError
93
+ nil
94
+ end
95
+
76
96
  def write_result(path, result)
77
97
  encoded = begin
78
98
  Marshal.dump(result)
@@ -116,7 +136,8 @@ module Bitfab
116
136
  result_path = File.join(@directory, "#{index}.result")
117
137
  assignment_path = File.join(@directory, "#{index}.json")
118
138
  stdout_path, stderr_path = File.join(@directory, "#{index}.out"), File.join(@directory, "#{index}.err")
119
- assignment = {pipeline: @args.fetch(:pipeline), params: @params, item:, test_run_id:, mock:, include_db_branch_lease:, db_branch_settings:, dry_run:, result_path:}
139
+ delivery_problems_path = File.join(@directory, "#{index}.delivery")
140
+ assignment = {pipeline: @args.fetch(:pipeline), params: @params, item:, test_run_id:, mock:, include_db_branch_lease:, db_branch_settings:, dry_run:, result_path:, delivery_problems_path:}
120
141
  File.write(assignment_path, JSON.generate(assignment), mode: "w", perm: 0o600)
121
142
  executable = File.expand_path("../../exe/bitfab-replay", __dir__)
122
143
  pid = @mutex.synchronize do
@@ -154,6 +175,8 @@ module Bitfab
154
175
  end
155
176
  child_errors = read_tail(stderr_path)
156
177
  @mutex.synchronize { @stderr.write(child_errors) } unless child_errors.empty?
178
+ delivery_problems = File.file?(delivery_problems_path) ? File.read(delivery_problems_path) : ""
179
+ @mutex.synchronize { @stderr.write(delivery_problems) } unless delivery_problems.empty? || child_errors.include?(delivery_problems)
157
180
  unless File.file?(result_path)
158
181
  output_tail = read_tail(stdout_path)
159
182
  @mutex.synchronize { @stderr.write(output_tail) } unless output_tail.empty?
@@ -220,7 +220,8 @@ module Bitfab
220
220
  end,
221
221
  memory_throttle: args.fetch(:memory_throttle, configured ? configured.memory_throttle : ReplayConcurrency::UNSET),
222
222
  on_item_finish_in_child_process: configured&.on_item_finish_in_child_process,
223
- child_timeout: configured&.child_timeout || 2400
223
+ child_timeout: configured&.child_timeout || 2400,
224
+ child_delivery_timeout: configured&.child_delivery_timeout || ReplayConcurrency::UNSET
224
225
  )
225
226
  ReplayProcesses.current&.configure(registry, options[:concurrency]) if options[:concurrency].primitive == "process"
226
227
  end
@@ -69,7 +69,7 @@ module Bitfab
69
69
  @mutex.synchronize { start_reader(0) if @thread.nil? && now >= @refresh_after }
70
70
  end
71
71
 
72
- def send_span(payload, &submit)
72
+ def send_span(payload, declared_node: false, discard: nil, &submit)
73
73
  refresh
74
74
  data = payload.dig("rawSpan", "span_data")
75
75
  key = payload["rootTraceFunctionKey"] || payload["traceFunctionKey"]
@@ -77,7 +77,7 @@ module Bitfab
77
77
  submit.call(payload)
78
78
  return
79
79
  end
80
- entry = [payload, key, submit]
80
+ entry = [payload, key, submit, discard, declared_node]
81
81
  ready = @mutex.synchronize { reading? ? hold(entry) : entry }
82
82
  deliver(*ready) if ready
83
83
  end
@@ -164,6 +164,7 @@ module Bitfab
164
164
  names = @mutex.synchronize { @content_off&.fetch(key, nil) }
165
165
  data = payload.dig("rawSpan", "span_data")
166
166
  return payload unless data.is_a?(Hash) && names&.include?(data["name"])
167
+ return nil unless root?(payload) || data["error"]
167
168
 
168
169
  without_content(payload, data)
169
170
  end
@@ -196,12 +197,13 @@ module Bitfab
196
197
  @held.shift if @held.length > MAX_HELD
197
198
  end
198
199
 
199
- def deliver(payload, key, submit)
200
- submit.call(prepare(payload, key))
200
+ def deliver(payload, key, submit, discard = nil, declared_node = false)
201
+ record = declared_node ? payload : prepare(payload, key)
202
+ record ? submit.call(record) : discard&.call(payload)
201
203
  end
202
204
 
203
- def deliver_safely(payload, key, submit)
204
- deliver(payload, key, submit)
205
+ def deliver_safely(payload, key, submit, discard = nil, declared_node = false)
206
+ deliver(payload, key, submit, discard, declared_node)
205
207
  rescue => error
206
208
  Bitfab.warn_once("sim-plan-held-span-dropped", "Held record could not be sent: #{error}")
207
209
  end
@@ -10,9 +10,8 @@ module Bitfab
10
10
 
11
11
  module Subtree
12
12
  DEFAULT_MAX_DEPTH = 30
13
- DEFAULT_MAX_SPANS = 2048
14
- DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 512
15
- LIMIT_ORDER = %i[max_depth max_spans max_captured_subtree_spans].freeze
13
+ DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 500
14
+ LIMIT_ORDER = %i[max_depth max_captured_subtree_spans].freeze
16
15
  SESSION_KEY = :bitfab_subtree_session
17
16
  DISPATCHER_KEY = :bitfab_subtree_dispatcher
18
17
  FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
@@ -53,6 +52,7 @@ module Bitfab
53
52
  :span_type,
54
53
  :test_run_id,
55
54
  :span_data,
55
+ :declared_node,
56
56
  keyword_init: true
57
57
  )
58
58
 
@@ -175,7 +175,7 @@ module Bitfab
175
175
  CaptureConfig.new(root: nil, root_path: nil, exclude: {}.freeze).freeze
176
176
  end
177
177
 
178
- def capture(client:, trace_function_key:, config:, max_depth:, max_spans:, include_wrappers:,
178
+ def capture(client:, trace_function_key:, config:, max_depth:, include_wrappers:,
179
179
  root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
180
180
  return yield unless config.root
181
181
 
@@ -193,7 +193,6 @@ module Bitfab
193
193
  input_source_span_id: replay&.dig(:input_source_span_id),
194
194
  config:,
195
195
  max_depth:,
196
- max_spans:,
197
196
  max_captured_subtree_spans:,
198
197
  include_wrappers:,
199
198
  mock_on_replay_default:,
@@ -418,7 +417,7 @@ module Bitfab
418
417
  end
419
418
 
420
419
  def initialize(client:, trace_function_key:, trace_id:, root_span_id:, test_run_id:,
421
- input_source_span_id:, config:, max_depth:, max_spans:, include_wrappers:,
420
+ input_source_span_id:, config:, max_depth:, include_wrappers:,
422
421
  root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
423
422
  @mock_on_replay_default = mock_on_replay_default
424
423
  @client = client
@@ -433,8 +432,7 @@ module Bitfab
433
432
  @root_method_name = root_method_name.to_sym
434
433
  @skip_root_call = true
435
434
  @max_depth = max_depth
436
- @max_spans = max_spans
437
- @max_captured_subtree_spans = [max_captured_subtree_spans, max_spans].min
435
+ @max_captured_subtree_spans = max_captured_subtree_spans
438
436
  @exclude = config.exclude
439
437
  @include_wrappers = include_wrappers
440
438
  @stacks = {}
@@ -443,7 +441,6 @@ module Bitfab
443
441
  @suppressed_depths = {}
444
442
  @owned_paths = {}
445
443
  @method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
446
- @spans_used = 1
447
444
  @captured_subtree_count = 0
448
445
  @limits_fired = Set.new
449
446
  @dropped_spans = 0
@@ -524,14 +521,9 @@ module Bitfab
524
521
 
525
522
  def reserve_node
526
523
  stack = current_stack(Fiber.current.object_id)
527
- if captured_depth(stack) >= @max_depth
528
- drop(:max_depth)
529
- return false
530
- end
531
- return true if take_slot(discovered: false)
524
+ return true if captured_depth(stack) < @max_depth
532
525
 
533
- drop(:max_spans)
534
- @exhausted = true
526
+ drop(:max_depth)
535
527
  false
536
528
  end
537
529
 
@@ -547,7 +539,7 @@ module Bitfab
547
539
  span_id: SecureRandom.uuid,
548
540
  parent_span_id: current_parent_span_id,
549
541
  name:, span_type: type, args:, kwargs:, test_run_id:, started_at: now,
550
- span_data: {}
542
+ span_data: {}, declared_node: true
551
543
  )
552
544
  @configured_entries[entry.object_id] = entry
553
545
  entry
@@ -652,9 +644,9 @@ module Bitfab
652
644
  return
653
645
  end
654
646
  content_off = content_off?(metadata.name)
655
- if (limit = limit_reached(discovered: true, content_off:))
656
- drop(limit)
657
- stop_discovery(fiber_id) unless limit == :max_captured_subtree_spans && content_off_names?
647
+ if !content_off && @captured_subtree_count >= @max_captured_subtree_spans
648
+ drop(:max_captured_subtree_spans)
649
+ stop_discovery(fiber_id) unless content_off_names?
658
650
  return
659
651
  end
660
652
 
@@ -665,7 +657,7 @@ module Bitfab
665
657
  return
666
658
  end
667
659
 
668
- count_span(discovered: true, content_off:)
660
+ @captured_subtree_count += 1 unless content_off
669
661
  span_id = SecureRandom.uuid
670
662
  without_content = content_off || plan_unavailable?
671
663
  args, kwargs = without_content ? [[], {}] : inputs(event)
@@ -741,6 +733,7 @@ module Bitfab
741
733
  span_name: entry.name,
742
734
  span_type: entry.span_type || "function",
743
735
  instrumentation: "trace",
736
+ declared_node: entry.declared_node == true,
744
737
  span_data: entry.span_data || {},
745
738
  function_name: entry.name,
746
739
  contexts: nil,
@@ -890,24 +883,6 @@ module Bitfab
890
883
  @suppressed_depths[fiber_id] = 1
891
884
  end
892
885
 
893
- def limit_reached(discovered:, content_off: false)
894
- return :max_spans if @spans_used >= @max_spans
895
-
896
- :max_captured_subtree_spans if discovered && !content_off && @captured_subtree_count >= @max_captured_subtree_spans
897
- end
898
-
899
- def take_slot(discovered:)
900
- return false if limit_reached(discovered:)
901
-
902
- count_span(discovered:, content_off: false)
903
- true
904
- end
905
-
906
- def count_span(discovered:, content_off:)
907
- @spans_used += 1
908
- @captured_subtree_count += 1 if discovered && !content_off
909
- end
910
-
911
886
  def stop_discovery(fiber_id)
912
887
  @exhausted = true
913
888
  if captured_calls_drained?
@@ -968,7 +943,7 @@ module Bitfab
968
943
  Bitfab.warn_once(
969
944
  "subtree-truncated:#{@trace_function_key}",
970
945
  "'#{@trace_function_key}' hit a subtree capture limit (max_depth=#{@max_depth}, " \
971
- "max_captured_subtree_spans=#{@max_captured_subtree_spans}, max_spans=#{@max_spans}) and its trace is incomplete. " \
946
+ "max_captured_subtree_spans=#{@max_captured_subtree_spans}) and its trace is incomplete. " \
972
947
  "Raise the limits, add bitfab_node to the calls that matter, or narrow what is traced with exclude: [...]."
973
948
  )
974
949
  end
@@ -31,6 +31,10 @@ module Bitfab
31
31
  end
32
32
  end
33
33
 
34
+ def discard(trace_id, span_id)
35
+ synchronize { @traces[trace_id]&.dig(:span_ids)&.delete(span_id) }
36
+ end
37
+
34
38
  def finish(trace_id, span_id)
35
39
  ready = synchronize do
36
40
  entry = @traces[trace_id]
@@ -82,10 +82,9 @@ module Bitfab
82
82
  # The root records with the requested type; first-party Ruby methods called
83
83
  # beneath it record automatically as function spans.
84
84
  def self.trace(klass, method_name, trace_function_key:, name: nil, type: "custom",
85
- max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
85
+ max_depth: Subtree::DEFAULT_MAX_DEPTH,
86
86
  max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
87
87
  exclude: [], include_wrappers: false, mock_on_replay_default: false, client: nil)
88
- validate_trace_options!(max_spans:)
89
88
  capture_config = Subtree.prepare(root_source: source_of(klass, method_name), exclude:)
90
89
  method_name_str = method_name.to_s
91
90
  bound_client = client
@@ -106,7 +105,7 @@ module Bitfab
106
105
  SpanContext.current[:surface] = :trace
107
106
  next Subtree.capture(
108
107
  client: target_client, trace_function_key:, config: capture_config,
109
- max_depth:, max_spans:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
108
+ max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
110
109
  root_receiver: self, root_method_name: method_name
111
110
  ) { super(*args, **kwargs, &block) }
112
111
  end
@@ -120,7 +119,7 @@ module Bitfab
120
119
  around_body: call.method(:run)) do
121
120
  Subtree.capture(
122
121
  client: target_client, trace_function_key:, config: capture_config,
123
- max_depth:, max_spans:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
122
+ max_depth:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
124
123
  root_receiver: self, root_method_name: method_name
125
124
  ) { super(*args, **kwargs, &block) }
126
125
  end
@@ -165,7 +164,7 @@ module Bitfab
165
164
  span_name:, span_type: type, function_name: method_name_str,
166
165
  args:, kwargs:, capture_when: "nested", mock_on_replay: policy,
167
166
  explicit_span_receiver: self, explicit_span_method_name: method_name,
168
- test_run_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?,
167
+ test_run_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?, declared_node: true,
169
168
  on_start: call.method(:start), on_complete: call.method(:complete),
170
169
  around_body: call.method(:run)) { super(*args, **kwargs, &block) }
171
170
  end
@@ -193,14 +192,6 @@ module Bitfab
193
192
  end
194
193
  private_class_method :preserve_visibility
195
194
 
196
- def self.validate_trace_options!(max_spans:)
197
- return if max_spans.is_a?(Integer) && max_spans >= 1
198
-
199
- raise ArgumentError,
200
- "bitfab_trace max_spans must be an integer of at least 1 because the root span takes one slot, got #{max_spans.inspect}."
201
- end
202
- private_class_method :validate_trace_options!
203
-
204
195
  def self.validate_node_options!(capture:, mock_on_replay:)
205
196
  return unless !capture && mock_on_replay
206
197
 
@@ -304,10 +295,9 @@ module Bitfab
304
295
  # EXPERIMENTAL. Trace a method and every first-party Ruby method it calls.
305
296
  # Descendants require no Bitfab declarations and record as function spans.
306
297
  def bitfab_trace(method_name, trace_function_key: nil, name: nil, type: "custom",
307
- max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
298
+ max_depth: Subtree::DEFAULT_MAX_DEPTH,
308
299
  max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
309
300
  exclude: [], include_wrappers: false, mock_on_replay_default: false)
310
- Traceable.send(:validate_trace_options!, max_spans:)
311
301
  trace_function_key ||= @bitfab_function_key
312
302
  unless trace_function_key
313
303
  raise "No trace function key provided. Pass `trace_function_key:` to `bitfab_trace` " \
@@ -319,7 +309,6 @@ module Bitfab
319
309
  name:,
320
310
  type:,
321
311
  max_depth:,
322
- max_spans:,
323
312
  max_captured_subtree_spans:,
324
313
  exclude:,
325
314
  include_wrappers:,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.57.7"
4
+ VERSION = "0.58.0"
5
5
  end
@@ -12,6 +12,8 @@ module Bitfab
12
12
  # warns once, not just the first one seen.
13
13
  @warn_once_seen = Set.new
14
14
  @warn_once_mutex = Mutex.new
15
+ @delivery_problems = nil
16
+ @delivery_problems_mutex = Mutex.new
15
17
 
16
18
  class << self
17
19
  def warn_once(key, message)
@@ -37,6 +39,27 @@ module Bitfab
37
39
  # Logging must never crash the host app (e.g. a closed $stderr).
38
40
  end
39
41
 
42
+ def warn_delivery_problem(message)
43
+ count_delivery_problem(message)
44
+ warn_always(message)
45
+ end
46
+
47
+ def count_delivery_problem(message)
48
+ @delivery_problems_mutex.synchronize { @delivery_problems[message] += 1 if @delivery_problems }
49
+ end
50
+
51
+ def delivery_problem_counts
52
+ @delivery_problems_mutex.synchronize { @delivery_problems ? @delivery_problems.dup : {} }
53
+ end
54
+
55
+ def collect_delivery_problems
56
+ problems = Hash.new(0)
57
+ @delivery_problems_mutex.synchronize { @delivery_problems = problems }
58
+ yield problems
59
+ ensure
60
+ @delivery_problems_mutex.synchronize { @delivery_problems = nil }
61
+ end
62
+
40
63
  # Test-only: clear the dedup set so a warning can fire again.
41
64
  def _reset_warn_once
42
65
  @warn_once_mutex.synchronize { @warn_once_seen.clear }
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.57.7
4
+ version: 0.58.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team