bitfab 0.57.6 → 0.57.8

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: 34dfb7ca50f4c2dceaa4aa4ed46ee6e9426a5d8051a3602084fee0640a77c9e9
4
- data.tar.gz: ad1e112ccdfc9a2cee2860b602e3edf3629686ef6228e81ac4b80f5132ae1fc1
3
+ metadata.gz: 187464e848086662f6d74efe91dfe95476bdae7284d7ba5668f49b2a4af4c606
4
+ data.tar.gz: 16fce40beaf7dc062e5d6d31624d6393730847b1eaee578b60459b5f0d27897a
5
5
  SHA512:
6
- metadata.gz: 764ad6880ee3656d90d28ecb5571298cc54ab2bd1bc6a6b2b07399fd2bee8fd151ae3adcfdbf9f2bdc8f7c0d0fccbb1da29e33c93dc68353e11058be22dc9cc8
7
- data.tar.gz: afadb371e2d697bab8aaac2a4f02a57491ab37745b6b756ca3302931915f6fec7eabcc80d4789e79f06a5f5b9dae9ab22627a1fc4f35a7f2f610d175da2c12ef
6
+ metadata.gz: 7a5f7f9cac1900dca07228819c1c40965cf020deb5adba9f9827c36c76265911e79c5ce3aca16db5e486c354a436f63e9ac487896af9c2cf522581886424887d
7
+ data.tar.gz: 560d27de6a0766f9625b82bcf33053ebfffd3792dbaf2a21cd59ddd7a129742d265965d086d68138566dee51a5ba02893fdcf0bf67a95ebc8d24bc4d37ecf6ff
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`), `max_spans:` (default `10000`), `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`.
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/baml.rb CHANGED
@@ -170,7 +170,6 @@ module Bitfab
170
170
  end
171
171
 
172
172
  class Client
173
- # Execute a stored BAML prompt locally and return its parsed output.
174
173
  def call(method_name, **inputs)
175
174
  version = @http_client.request("/api/sdk/functions/lookup", {"name" => method_name})
176
175
  raise ArgumentError, "Function #{method_name.inspect} not found. Create it at #{@service_url}/functions" unless version["id"]
@@ -182,7 +181,9 @@ module Bitfab
182
181
  rescue
183
182
  result.to_s
184
183
  end
185
- @http_client.send_internal_trace(version["id"], {"result" => output, "inputs" => inputs, "rawCollector" => collector, "source" => "ruby-sdk"})
184
+ if should_record? && !active_span_content_withheld?
185
+ @http_client.send_internal_trace(version["id"], {"result" => output, "inputs" => inputs, "rawCollector" => collector, "source" => "ruby-sdk"})
186
+ end
186
187
  result
187
188
  end
188
189
 
data/lib/bitfab/client.rb CHANGED
@@ -49,14 +49,10 @@ module Bitfab
49
49
  @enabled_in_code = capture_enabled.nil? ? enabled : capture_enabled
50
50
  @capture_configured_from_env = nil
51
51
  @strict = strict
52
- # Cached only once a non-empty key is found, so an early resolve (before
53
- # env loaded) can't poison a later one.
54
52
  @resolved_api_key = nil
55
53
  @api_key_warned = false
56
- # The key is NOT read here. HttpClient gets a proc so the key is resolved
57
- # at send time, after any env loading has run.
58
54
  @http_client = HttpClient.new(api_key: -> { resolve_api_key }, service_url: @service_url, timeout:)
59
- @http_client.configure_simulation_plan(enabled: simulation_plan)
55
+ @http_client.configure_simulation_plan(enabled: simulation_plan, start: !api_key_resolved_lazily?)
60
56
  @datasets = Datasets.new(@http_client)
61
57
  @assertion_categories = AssertionCategories.new(@http_client)
62
58
  @traces = Traces.new(@http_client)
@@ -381,11 +377,10 @@ module Bitfab
381
377
  explicit_span_receiver: nil, explicit_span_method_name: nil, test_run_id: nil, finalize: nil,
382
378
  surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
383
379
  result_recorded_elsewhere: false)
384
- # Decide at CALL time, not construction. The key may be set after the
385
- # client is built (env loaded later), so re-checking per call lets a
386
- # late-resolved key take effect.
387
380
  return yield unless should_record?
388
381
 
382
+ @http_client.refresh_simulation_plan
383
+
389
384
  resolved_capture_when = capture_when.to_s
390
385
  unless ["always", "nested"].include?(resolved_capture_when)
391
386
  Bitfab.warn_once(
@@ -513,6 +508,12 @@ module Bitfab
513
508
  completed = false
514
509
  @http_client.trace_completion.start(trace_id, span_id)
515
510
  recording_override = managed_root ? {} : nil
511
+ content_withheld = lambda do
512
+ instrumentation = recording_override&.dig(:instrumentation) || ((surface == :trace) ? "trace" : "span")
513
+ content_off_by_simulation_plan?(
514
+ trace_id:, trace_function_key:, span_name: recording_override&.dig(:name) || span_name, instrumentation:
515
+ ) || (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation:))
516
+ end
516
517
 
517
518
  complete = lambda do |final_result, final_error|
518
519
  # Never crash the host app due to span building/sending. Idempotent:
@@ -528,9 +529,7 @@ module Bitfab
528
529
  recorded_error = final_error
529
530
  recorded_name = recording_override&.dig(:name) || span_name
530
531
  recorded_instrumentation = recording_override&.dig(:instrumentation) || ((surface == :trace) ? "trace" : "span")
531
- content_off = content_off_by_simulation_plan?(
532
- trace_id:, trace_function_key:, span_name: recorded_name, instrumentation: recorded_instrumentation
533
- ) || (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation: recorded_instrumentation))
532
+ content_off = content_withheld.call
534
533
  if finalize && final_error.nil? && !(content_off && !result_recorded_elsewhere)
535
534
  begin
536
535
  recorded_result = finalize.call(final_result)
@@ -613,7 +612,7 @@ module Bitfab
613
612
  span_id:,
614
613
  explicit_span_receiver:,
615
614
  explicit_span_method_name: explicit_span_method_name&.to_sym,
616
- surface:, recording_override:
615
+ surface:, recording_override:, content_withheld:
617
616
  ) do
618
617
  invoke = -> { around_body ? around_body.call { yield } : yield }
619
618
  result = if explicit_span_receiver
@@ -704,6 +703,18 @@ module Bitfab
704
703
  nil
705
704
  end
706
705
 
706
+ def active_span_content_withheld?
707
+ return true if Subtree.current_session&.current_span_content_withheld?
708
+
709
+ SpanContext.current&.dig(:content_withheld)&.call == true
710
+ rescue
711
+ true
712
+ end
713
+
714
+ def api_key_resolved_lazily?
715
+ @api_key_config.respond_to?(:call) || @api_key_config.to_s.strip.empty?
716
+ end
717
+
707
718
  # Resolve the API key lazily, the first time a span actually needs it.
708
719
  #
709
720
  # The key is intentionally NOT read at construction: a client built when a
@@ -955,7 +966,7 @@ module Bitfab
955
966
  span_data["error_source"] = "code"
956
967
  end
957
968
  span_data["contexts"] = contexts if contexts&.any?
958
- span_data["prompt"] = prompt if prompt
969
+ span_data["prompt"] = prompt if prompt && !content_off
959
970
 
960
971
  raw_span = {
961
972
  "id" => span_id,
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+
5
+ module Bitfab
6
+ class ConnectionPool
7
+ KEEP_ALIVE_TIMEOUT_SECONDS = 30
8
+ MAX_IDLE_CONNECTIONS = 64
9
+
10
+ IdleConnection = Struct.new(:http, :released_at)
11
+ private_constant :IdleConnection
12
+
13
+ @registry = ObjectSpace::WeakMap.new
14
+ @registry_mutex = Mutex.new
15
+
16
+ class << self
17
+ def register(pool)
18
+ @registry_mutex.synchronize { @registry[pool] = true }
19
+ end
20
+
21
+ def close_all
22
+ pools = @registry_mutex.synchronize { @registry.keys }
23
+ pools.each(&:close)
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ @pid = Process.pid
29
+ @mutex = Mutex.new
30
+ @idle = {}
31
+ self.class.register(self)
32
+ end
33
+
34
+ def with_connection(uri, timeout)
35
+ key = [uri.scheme, uri.host, uri.port]
36
+ owner_pid = Process.pid
37
+ reusable = false
38
+ http = checkout(key, uri, timeout)
39
+ response = yield http
40
+ reusable = true
41
+ response
42
+ ensure
43
+ release(key, http, owner_pid, reusable) if http
44
+ end
45
+
46
+ def close
47
+ drain.each { |http| finish(http) }
48
+ end
49
+
50
+ def idle_count
51
+ reset_after_fork
52
+ @mutex.synchronize { @idle.sum { |_key, connections| connections.size } }
53
+ end
54
+
55
+ private
56
+
57
+ def checkout(key, uri, timeout)
58
+ http = take_idle(key) || connect(uri, timeout)
59
+ http.read_timeout = timeout
60
+ http.write_timeout = timeout
61
+ http
62
+ end
63
+
64
+ def connect(uri, timeout)
65
+ http = Net::HTTP.new(uri.host, uri.port)
66
+ http.use_ssl = uri.scheme == "https"
67
+ http.open_timeout = timeout
68
+ http.read_timeout = timeout
69
+ http.write_timeout = timeout
70
+ http.keep_alive_timeout = KEEP_ALIVE_TIMEOUT_SECONDS
71
+ http.start
72
+ end
73
+
74
+ def take_idle(key)
75
+ reset_after_fork
76
+ now = monotonic_now
77
+ expired = []
78
+ http = @mutex.synchronize do
79
+ connections = @idle[key] || []
80
+ stale, fresh = connections.partition { |idle| now - idle.released_at >= KEEP_ALIVE_TIMEOUT_SECONDS }
81
+ expired.concat(stale.map(&:http))
82
+ @idle[key] = fresh
83
+ fresh.pop&.http
84
+ end
85
+ expired.each { |stale_http| finish(stale_http) }
86
+ http
87
+ end
88
+
89
+ def release(key, http, owner_pid, reusable)
90
+ return unless owner_pid == Process.pid
91
+
92
+ unless reusable && http.started?
93
+ finish(http)
94
+ return
95
+ end
96
+
97
+ reset_after_fork
98
+ pooled = @mutex.synchronize do
99
+ next false if @idle.sum { |_key, connections| connections.size } >= MAX_IDLE_CONNECTIONS
100
+
101
+ (@idle[key] ||= []) << IdleConnection.new(http, monotonic_now)
102
+ true
103
+ end
104
+ finish(http) unless pooled
105
+ end
106
+
107
+ def drain
108
+ return [] if reset_after_fork
109
+
110
+ @mutex.synchronize do
111
+ connections = @idle.values.flatten.map(&:http)
112
+ @idle = {}
113
+ connections
114
+ end
115
+ end
116
+
117
+ def reset_after_fork
118
+ return false if @pid == Process.pid
119
+
120
+ @pid = Process.pid
121
+ @mutex = Mutex.new
122
+ @idle = {}
123
+ true
124
+ end
125
+
126
+ def finish(http)
127
+ http.finish if http.started?
128
+ rescue
129
+ nil
130
+ end
131
+
132
+ def monotonic_now
133
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
134
+ end
135
+ end
136
+ end
@@ -6,6 +6,7 @@ require "uri"
6
6
 
7
7
  require_relative "git_state"
8
8
  require_relative "compress"
9
+ require_relative "connection_pool"
9
10
  require_relative "constants"
10
11
  require_relative "serialize"
11
12
  require_relative "transport"
@@ -37,6 +38,7 @@ module Bitfab
37
38
  @api_key = api_key
38
39
  @service_url = (service_url || DEFAULT_SERVICE_URL).chomp("/")
39
40
  @timeout = timeout
41
+ @connections = ConnectionPool.new
40
42
  @transport_mutex = Mutex.new
41
43
  # Eager for the same reason the transport's mutex is: `||=` racing between
42
44
  # the export threads recording acks would hand each a different mutex.
@@ -53,9 +55,9 @@ module Bitfab
53
55
  @close_condition = ConditionVariable.new
54
56
  end
55
57
 
56
- def configure_simulation_plan(enabled: true)
58
+ def configure_simulation_plan(enabled: true, start: true)
57
59
  @simulation_plan = SimulationPlan.new(self, enabled:)
58
- @simulation_plan.refresh
60
+ @simulation_plan.refresh if start
59
61
  @simulation_plan
60
62
  end
61
63
 
@@ -80,7 +82,7 @@ module Bitfab
80
82
  end
81
83
 
82
84
  def get_simulation_plan
83
- get("/api/sdk/sim-plan", timeout: SimulationPlan::READ_TIMEOUT, connection_close: true)
85
+ get("/api/sdk/sim-plan", timeout: SimulationPlan::READ_TIMEOUT)
84
86
  end
85
87
 
86
88
  # Flush and permanently close this client's tracing transport.
@@ -112,6 +114,7 @@ module Bitfab
112
114
  shutdown = transport.nil? || transport.shutdown(remaining)
113
115
  result = released && shutdown
114
116
  ensure
117
+ @connections.close
115
118
  @transport_mutex.synchronize do
116
119
  @close_result = result
117
120
  @close_condition.broadcast
@@ -157,16 +160,11 @@ module Bitfab
157
160
  last_error = nil
158
161
 
159
162
  max_retries.times do |attempt|
160
- http = Net::HTTP.new(uri.host, uri.port)
161
- http.use_ssl = uri.scheme == "https"
162
- http.open_timeout = request_timeout
163
- http.read_timeout = request_timeout
164
-
165
163
  req = Net::HTTPGenericRequest.new(method, true, true, uri.request_uri, headers)
166
164
  req["Content-Encoding"] = prepared.content_encoding if prepared.content_encoding
167
165
  req.body = prepared.body
168
166
 
169
- response = http.request(req)
167
+ response = @connections.with_connection(uri, request_timeout) { |http| http.request(req) }
170
168
 
171
169
  unless response.is_a?(Net::HTTPSuccess)
172
170
  raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
@@ -216,19 +214,12 @@ module Bitfab
216
214
 
217
215
  # Make a GET request to the Bitfab API.
218
216
  # Returns parsed JSON response hash.
219
- def get(endpoint, timeout: nil, connection_close: false)
217
+ def get(endpoint, timeout: nil)
220
218
  uri = URI("#{@service_url}#{endpoint}")
221
219
  request_timeout = timeout || @timeout
222
220
 
223
- http = Net::HTTP.new(uri.host, uri.port)
224
- http.use_ssl = uri.scheme == "https"
225
- http.open_timeout = request_timeout
226
- http.read_timeout = request_timeout
227
-
228
- # request_uri (not path) so any query string on the endpoint survives.
229
221
  req = Net::HTTP::Get.new(uri.request_uri, headers)
230
- req["Connection"] = "close" if connection_close
231
- response = http.request(req)
222
+ response = @connections.with_connection(uri, request_timeout) { |http| http.request(req) }
232
223
 
233
224
  unless response.is_a?(Net::HTTPSuccess)
234
225
  raise Net::HTTPError.new("HTTP #{response.code}: #{response.body}", response)
@@ -742,5 +733,6 @@ module Bitfab
742
733
  at_exit do
743
734
  SimulationPlan.release_all(2, stop: true)
744
735
  Transport.shutdown_trace_transports(2)
736
+ ConnectionPool.close_all
745
737
  end
746
738
  end
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Bitfab
4
4
  class SimulationPlan
5
- CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized].freeze
5
+ CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized prompt].freeze
6
6
  FRAMEWORKS = %w[openai-agents langgraph claude-agent-sdk vercel-ai].freeze
7
7
  READ_TIMEOUT = 5.0
8
8
  REFRESH_SECONDS = 60.0
@@ -54,6 +54,7 @@ module Bitfab
54
54
  @draining = Set.new
55
55
  @stopped = false
56
56
  @first_read_finished = false
57
+ @first_read_deadline = nil
57
58
  self.class.register(self)
58
59
  end
59
60
 
@@ -144,14 +145,13 @@ module Bitfab
144
145
  @mutex.synchronize { @content_off.nil? && !reading? }
145
146
  end
146
147
 
147
- def wait_for_first_read(timeout = READ_TIMEOUT)
148
- reset_after_fork
148
+ def wait_for_first_read
149
+ refresh
149
150
  return if disabled?
150
151
 
151
- deadline = now + [timeout, 0].max
152
152
  @mutex.synchronize do
153
- while reading? && @thread
154
- remaining = deadline - now
153
+ while reading? && @thread && @first_read_deadline
154
+ remaining = @first_read_deadline - now
155
155
  break unless remaining.positive?
156
156
  @condition.wait(@mutex, remaining)
157
157
  end
@@ -229,6 +229,7 @@ module Bitfab
229
229
  end
230
230
 
231
231
  def start_reader(delay)
232
+ @first_read_deadline ||= now + delay + READ_TIMEOUT
232
233
  @thread = Thread.new { read_until_loaded(delay) }
233
234
  @thread.name = "bitfab-sim-plan"
234
235
  rescue ThreadError
@@ -302,6 +303,7 @@ module Bitfab
302
303
  @thread = nil
303
304
  @held = []
304
305
  @draining = Set.new
306
+ @first_read_deadline = nil unless @first_read_finished
305
307
  end
306
308
  end
307
309
  end
@@ -150,10 +150,11 @@ module Bitfab
150
150
 
151
151
  # Execute a block with a new span pushed onto the stack.
152
152
  # The span is automatically popped when the block completes.
153
- def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil, surface: nil, recording_override: nil)
153
+ def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil, surface: nil, recording_override: nil, content_withheld: nil)
154
154
  entry = {trace_id:, span_id:}
155
155
  entry[:surface] = surface if surface
156
156
  entry[:recording_override] = recording_override if recording_override
157
+ entry[:content_withheld] = content_withheld if content_withheld
157
158
  if explicit_span_receiver
158
159
  entry[:explicit_span_receiver] = explicit_span_receiver
159
160
  entry[:explicit_span_method_name] = explicit_span_method_name
@@ -10,8 +10,9 @@ 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
13
+ DEFAULT_MAX_SPANS = 10_000
14
+ DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 500
15
+ LIMIT_ORDER = %i[max_depth max_spans max_captured_subtree_spans].freeze
15
16
  SESSION_KEY = :bitfab_subtree_session
16
17
  DISPATCHER_KEY = :bitfab_subtree_dispatcher
17
18
  FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
@@ -444,7 +445,8 @@ module Bitfab
444
445
  @method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
445
446
  @spans_used = 1
446
447
  @captured_subtree_count = 0
447
- @truncated = false
448
+ @limits_fired = Set.new
449
+ @dropped_spans = 0
448
450
  @exhausted = false
449
451
  @drained = false
450
452
  @deferred_frames = []
@@ -478,7 +480,7 @@ module Bitfab
478
480
  unless @deferred
479
481
  @fiber_parents.clear
480
482
  finish_open_spans
481
- warn_if_truncated
483
+ record_truncation
482
484
  end
483
485
  end
484
486
 
@@ -491,7 +493,7 @@ module Bitfab
491
493
  end
492
494
 
493
495
  def handle(event)
494
- return if @drained
496
+ return count_drained_call(event) if @drained
495
497
 
496
498
  if event.event == :raise
497
499
  record_error(event)
@@ -523,16 +525,21 @@ module Bitfab
523
525
  def reserve_node
524
526
  stack = current_stack(Fiber.current.object_id)
525
527
  if captured_depth(stack) >= @max_depth
526
- @truncated = true
528
+ drop(:max_depth)
527
529
  return false
528
530
  end
529
531
  return true if take_slot(discovered: false)
530
532
 
531
- @truncated = true
533
+ drop(:max_spans)
532
534
  @exhausted = true
533
535
  false
534
536
  end
535
537
 
538
+ def current_span_content_withheld?
539
+ entry = @stacks[Fiber.current.object_id]&.reverse_each&.find { |candidate| candidate.is_a?(OpenSpan) }
540
+ entry&.span_data&.dig("content_off_by_simulation_plan") == true
541
+ end
542
+
536
543
  def configured_entry(name:, type:, args:, kwargs:, test_run_id:)
537
544
  return unless reserve_node
538
545
 
@@ -624,11 +631,13 @@ module Bitfab
624
631
  fiber_id = Fiber.current.object_id
625
632
  if @suppressed_depths[fiber_id].to_i.positive?
626
633
  @suppressed_depths[fiber_id] += 1
634
+ count_dropped_call(event, resolved_path)
627
635
  return
628
636
  end
629
637
 
630
638
  if @exhausted
631
639
  suppress(fiber_id)
640
+ count_dropped_call(event, resolved_path)
632
641
  return
633
642
  end
634
643
 
@@ -638,13 +647,13 @@ module Bitfab
638
647
 
639
648
  stack = current_stack(fiber_id)
640
649
  if captured_depth(stack) >= @max_depth
641
- @truncated = true
650
+ drop(:max_depth)
642
651
  suppress(fiber_id)
643
652
  return
644
653
  end
645
654
  content_off = content_off?(metadata.name)
646
655
  if (limit = limit_reached(discovered: true, content_off:))
647
- @truncated = true
656
+ drop(limit)
648
657
  stop_discovery(fiber_id) unless limit == :max_captured_subtree_spans && content_off_names?
649
658
  return
650
659
  end
@@ -927,9 +936,35 @@ module Bitfab
927
936
  false
928
937
  end
929
938
 
930
- def warn_if_truncated
931
- return unless @truncated
939
+ def drop(limit)
940
+ @limits_fired << limit
941
+ @dropped_spans += 1
942
+ end
943
+
944
+ def count_dropped_call(event, resolved_path)
945
+ metadata = metadata_for(event, resolved_path)
946
+ return if metadata.excluded || (!@include_wrappers && metadata.passthrough)
947
+
948
+ @dropped_spans += 1
949
+ end
950
+
951
+ def count_drained_call(event)
952
+ return unless event.event == :call
953
+ return if skip_boundary_call?(event)
932
954
 
955
+ resolved_path = owned_path(event.path)
956
+ count_dropped_call(event, resolved_path) if resolved_path
957
+ rescue Exception # rubocop:disable Lint/RescueException
958
+ nil
959
+ end
960
+
961
+ def record_truncation
962
+ return if @limits_fired.empty?
963
+
964
+ CurrentTrace.new(@trace_id).set_metadata(
965
+ "bitfab.truncated_by" => LIMIT_ORDER.select { |limit| @limits_fired.include?(limit) }.join(","),
966
+ "bitfab.dropped_spans" => @dropped_spans
967
+ )
933
968
  Bitfab.warn_once(
934
969
  "subtree-truncated:#{@trace_function_key}",
935
970
  "'#{@trace_function_key}' hit a subtree capture limit (max_depth=#{@max_depth}, " \
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.57.6"
4
+ VERSION = "0.57.8"
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.6
4
+ version: 0.57.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -161,6 +161,7 @@ files:
161
161
  - lib/bitfab/client.rb
162
162
  - lib/bitfab/commit_ref.rb
163
163
  - lib/bitfab/compress.rb
164
+ - lib/bitfab/connection_pool.rb
164
165
  - lib/bitfab/constants.rb
165
166
  - lib/bitfab/datasets.rb
166
167
  - lib/bitfab/db_snapshot.rb