bitfab 0.51.10 → 0.57.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: c37ea054aa21aac4e6e10ff309ea4b7287ccf04b2b80692b4b260577d9f59ea9
4
- data.tar.gz: 75f5892300254c708c9dac68cdb2a1cf125adf162a89c275e504546dca930cd4
3
+ metadata.gz: 1cd6fe2aaa2d03dea82e1d6a14b63e5a60eec3169c33489ce54795840ab6db8c
4
+ data.tar.gz: 1637726e4aa1cfa7940ecb65e1b10684b0d1236885a3c1ddd0d3748c8c7a874f
5
5
  SHA512:
6
- metadata.gz: 7436cd4d8ff50ee5dc6625b7f7f645fc8c31a60d38b80ee69dc93306b59a3d2ef781b7d5282ce6dfc857c3e4166a992c07f73faa1960bbfa84e87e675a17e1ce
7
- data.tar.gz: 2a5503c884dafc47cab0d23373265ebac0810bb2672868677b5335093eb7d47ed86e4a77dcd7e828e7222db52efbbf7196d312ffb5864aceadf3833f769f0cf9
6
+ metadata.gz: 391ca0dbc155bf7826f8ff4245d305d6d426cd8cd330a2c92564de8eac72ee229d928faad6d74f72f3d7163cf8a950fab1c6dfc96c5ddefdbd215ba0167382a1
7
+ data.tar.gz: c8a4c5172ecb6f78b309303062167073384bbdd7079c5fc5618200b877526a44bd495f85b2e6120b9a602542f883b80d2872ba2519f81109d3e17d83977b301f
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 `max_spans` stops new capture but allows already-open spans in every Fiber to finish. `max_depth:`, `max_spans:`, `exclude:`, and `include_wrappers:` control the subtree. 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 `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`.
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
 
@@ -258,7 +258,7 @@ end
258
258
 
259
259
  Nodes support `name:`, `type:`, `capture:`, `test_run_id:`, `mock_on_replay:`, and `finalize:`. `capture: false` omits the method while attaching captured descendants to its nearest visible parent. `finalize:` changes only the recorded output, never the return value. A finalizer failure is recorded on the node without crashing the caller. The before-`def`, inline, and after-`def` forms all preserve method visibility. For external code, use `Bitfab.client.node(MyClass, :method)`.
260
260
 
261
- `bitfab_trace` prepares its project boundary and exclusions when the method is declared, refreshes the boundary if the root definition moves during hot reload, caches canonical source paths, and shares one `TracePoint` dispatcher across active roots on the calling thread. After `max_spans` is exhausted, it stops collecting metadata and values immediately, then stops processing events for that session once its captured calls finish. Each active root still captures and serializes its own copy of every overlapping call, so this remains a discovery tool rather than the production default. Prefer explicit `bitfab_span` declarations on hot paths. Run `mise exec -- ruby -Ilib benchmark/subtree.rb` from this package to measure the no-network caller path on your Ruby build.
261
+ `bitfab_trace` prepares its project boundary and exclusions when the method is declared, refreshes the boundary if the root definition moves during hot reload, caches canonical source paths, and shares one `TracePoint` dispatcher across active roots on the calling thread. After a span limit is reached, it stops reading inputs and outputs immediately, then stops processing events for that session once its captured calls finish. Each active root still captures and serializes its own copy of every overlapping call, so this remains a discovery tool rather than the production default. Prefer explicit `bitfab_span` declarations on hot paths. Run `mise exec -- ruby -Ilib benchmark/subtree.rb` from this package to measure the no-network caller path on your Ruby build.
262
262
 
263
263
  ### Input/Output Capture
264
264
 
data/lib/bitfab/client.rb CHANGED
@@ -379,7 +379,8 @@ module Bitfab
379
379
  def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
380
380
  capture_when: "always", mock_on_replay: false, independent_root: false,
381
381
  explicit_span_receiver: nil, explicit_span_method_name: nil, test_run_id: nil, finalize: nil,
382
- surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false)
382
+ surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
383
+ result_recorded_elsewhere: false)
383
384
  # Decide at CALL time, not construction. The key may be set after the
384
385
  # client is built (env loaded later), so re-checking per call lets a
385
386
  # late-resolved key take effect.
@@ -525,7 +526,12 @@ module Bitfab
525
526
  ended_at = Bitfab.now_iso_timestamp
526
527
  recorded_result = final_result
527
528
  recorded_error = final_error
528
- if finalize && final_error.nil?
529
+ recorded_name = recording_override&.dig(:name) || span_name
530
+ 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))
534
+ if finalize && final_error.nil? && !(content_off && !result_recorded_elsewhere)
529
535
  begin
530
536
  recorded_result = finalize.call(final_result)
531
537
  rescue => e
@@ -537,13 +543,13 @@ module Bitfab
537
543
  on_complete&.call(result: recorded_result, error: recorded_error, ended_at:)
538
544
 
539
545
  send_span(
540
- span_data:,
541
- instrumentation: recording_override&.dig(:instrumentation) || ((surface == :trace) ? "trace" : "span"),
546
+ span_data: content_off ? span_data.merge("content_off_by_simulation_plan" => true) : span_data,
547
+ instrumentation: recorded_instrumentation,
542
548
  trace_function_key:,
543
549
  trace_id:,
544
550
  span_id:,
545
551
  parent_span_id:,
546
- span_name: recording_override&.dig(:name) || span_name,
552
+ span_name: recorded_name,
547
553
  span_type: recording_override&.dig(:type) || span_type,
548
554
  function_name: recording_override&.dig(:function_name) || function_name,
549
555
  contexts: span_contexts,
@@ -672,6 +678,32 @@ module Bitfab
672
678
  raise ArgumentError, "id must be a valid Bitfab span ID" unless id.is_a?(String) && UUID_PATTERN.match?(id)
673
679
  end
674
680
 
681
+ def content_off_by_simulation_plan?(trace_id:, trace_function_key:, span_name:, instrumentation: "span")
682
+ return false if SimulationPlan::FRAMEWORKS.include?(instrumentation)
683
+
684
+ @http_client.simulation_plan_content_off?(root_trace_function_key(trace_id, trace_function_key), span_name)
685
+ end
686
+
687
+ def simulation_plan_unavailable?(instrumentation: "span")
688
+ return false if SimulationPlan::FRAMEWORKS.include?(instrumentation)
689
+
690
+ @http_client.simulation_plan_unavailable?
691
+ end
692
+
693
+ def simulation_plan_content_off_names?(trace_id:, trace_function_key:)
694
+ @http_client.simulation_plan_content_off_names?(root_trace_function_key(trace_id, trace_function_key))
695
+ end
696
+
697
+ def root_trace_function_key(trace_id, trace_function_key)
698
+ TraceState.get(trace_id)&.dig(:trace_function_key) || trace_function_key
699
+ end
700
+
701
+ def wait_for_simulation_plan
702
+ @http_client.wait_for_simulation_plan
703
+ rescue
704
+ nil
705
+ end
706
+
675
707
  # Resolve the API key lazily, the first time a span actually needs it.
676
708
  #
677
709
  # The key is intentionally NOT read at construction: a client built when a
@@ -890,25 +922,34 @@ module Bitfab
890
922
  # Human-readable JSON (input/output fields). The *_with_report variants
891
923
  # also report what could not be faithfully captured so a lossy span is
892
924
  # marked non-replayable below instead of shipping as if it round-trips.
893
- human_inputs, input_dropped = Serialize.serialize_inputs_with_report(args, kwargs)
894
- human_output, output_dropped = Serialize.serialize_value_with_report(result)
895
- dropped = input_dropped + output_dropped
896
-
897
- # Marshal + Base64 for full Ruby-to-Ruby object reconstruction
898
- raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
899
- marshalled_input = Serialize.marshal_value(raw_input)
900
- marshalled_output = Serialize.marshal_value(result)
901
-
902
- span_data = {
903
- "name" => span_name,
904
- "type" => span_type,
905
- "input" => human_inputs,
906
- "output" => human_output,
907
- "function_name" => function_name
908
- }
909
- span_data.merge!(extra_span_data)
910
- span_data["input_serialized"] = marshalled_input if marshalled_input
911
- span_data["output_serialized"] = marshalled_output if marshalled_output
925
+ content_off = extra_span_data["content_off_by_simulation_plan"] == true ||
926
+ content_off_by_simulation_plan?(trace_id:, trace_function_key:, span_name:, instrumentation:) ||
927
+ (!parent_span_id.nil? && simulation_plan_unavailable?(instrumentation:))
928
+ if content_off
929
+ dropped = []
930
+ span_data = {"name" => span_name, "type" => span_type, "function_name" => function_name}
931
+ span_data.merge!(extra_span_data.except(*SimulationPlan::CONTENT_KEYS))
932
+ span_data["content_off_by_simulation_plan"] = true
933
+ else
934
+ human_inputs, input_dropped = Serialize.serialize_inputs_with_report(args, kwargs)
935
+ human_output, output_dropped = Serialize.serialize_value_with_report(result)
936
+ dropped = input_dropped + output_dropped
937
+
938
+ raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
939
+ marshalled_input = Serialize.marshal_value(raw_input)
940
+ marshalled_output = Serialize.marshal_value(result)
941
+
942
+ span_data = {
943
+ "name" => span_name,
944
+ "type" => span_type,
945
+ "input" => human_inputs,
946
+ "output" => human_output,
947
+ "function_name" => function_name
948
+ }
949
+ span_data.merge!(extra_span_data)
950
+ span_data["input_serialized"] = marshalled_input if marshalled_input
951
+ span_data["output_serialized"] = marshalled_output if marshalled_output
952
+ end
912
953
  if error
913
954
  span_data["error"] = error
914
955
  span_data["error_source"] = "code"
@@ -1172,6 +1213,7 @@ module Bitfab
1172
1213
  # Experimentally trace an existing method and its first-party call subtree.
1173
1214
  def trace(klass, method_name, name: nil, type: "custom",
1174
1215
  max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
1216
+ max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
1175
1217
  exclude: [], include_wrappers: false, mock_on_replay_default: false)
1176
1218
  Bitfab::Traceable.trace(
1177
1219
  klass, method_name,
@@ -1180,6 +1222,7 @@ module Bitfab
1180
1222
  type:,
1181
1223
  max_depth:,
1182
1224
  max_spans:,
1225
+ max_captured_subtree_spans:,
1183
1226
  exclude:,
1184
1227
  include_wrappers:,
1185
1228
  mock_on_replay_default:,
@@ -55,12 +55,30 @@ module Bitfab
55
55
 
56
56
  def configure_simulation_plan(enabled: true)
57
57
  @simulation_plan = SimulationPlan.new(self, enabled:)
58
+ @simulation_plan.refresh
59
+ @simulation_plan
58
60
  end
59
61
 
60
62
  def refresh_simulation_plan
61
63
  @simulation_plan&.refresh
62
64
  end
63
65
 
66
+ def wait_for_simulation_plan
67
+ @simulation_plan&.wait_for_first_read
68
+ end
69
+
70
+ def simulation_plan_content_off?(trace_function_key, span_name)
71
+ @simulation_plan&.content_off?(trace_function_key, span_name) == true
72
+ end
73
+
74
+ def simulation_plan_content_off_names?(trace_function_key)
75
+ @simulation_plan&.content_off_names?(trace_function_key) == true
76
+ end
77
+
78
+ def simulation_plan_unavailable?
79
+ @simulation_plan&.unavailable? == true
80
+ end
81
+
64
82
  def get_simulation_plan
65
83
  get("/api/sdk/sim-plan", timeout: SimulationPlan::READ_TIMEOUT, connection_close: true)
66
84
  end
@@ -82,8 +100,8 @@ module Bitfab
82
100
  result = false
83
101
  begin
84
102
  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
103
+ @simulation_plan&.release([remaining / 2.0, SimulationPlan::READ_TIMEOUT].min)
104
+ released = @simulation_plan.nil? || @simulation_plan.stop
87
105
  remaining = [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
88
106
  transport = @transport_mutex.synchronize do
89
107
  current = @transport
data/lib/bitfab/otel.rb CHANGED
@@ -26,7 +26,7 @@ module Bitfab
26
26
  MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
27
27
  MAX_QUEUE_SIZE = 8_192
28
28
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
29
- DIRECT_MAX_REQUEST_BATCH_SIZE = 8
29
+ DIRECT_MAX_REQUEST_BATCH_SIZE = 128
30
30
  DEFAULT_EXPORT_CONCURRENCY = 32
31
31
  MAX_EXPORT_CONCURRENCY = 64
32
32
  SCHEDULE_DELAY_MILLIS = 5_000
@@ -25,8 +25,8 @@ module Bitfab
25
25
  released = true
26
26
  plans.each do |plan|
27
27
  result = plan.release([deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max)
28
+ result = plan.stop if stop
28
29
  released = result && released
29
- plan.stop if stop
30
30
  end
31
31
  released
32
32
  end
@@ -53,6 +53,7 @@ module Bitfab
53
53
  @held = []
54
54
  @draining = Set.new
55
55
  @stopped = false
56
+ @first_read_finished = false
56
57
  self.class.register(self)
57
58
  end
58
59
 
@@ -71,29 +72,27 @@ module Bitfab
71
72
  refresh
72
73
  data = payload.dig("rawSpan", "span_data")
73
74
  key = payload["rootTraceFunctionKey"] || payload["traceFunctionKey"]
74
- if disabled? || @stopped || key.nil? || !data.is_a?(Hash) || framework?(payload)
75
+ if disabled? || key.nil? || !data.is_a?(Hash) || framework?(payload)
75
76
  submit.call(payload)
76
77
  return
77
78
  end
78
- held = @mutex.synchronize do
79
- if @content_off.nil?
80
- hold([payload, key, submit])
81
- true
82
- end
83
- end
84
- submit.call(apply(payload, key)) unless held
79
+ entry = [payload, key, submit]
80
+ ready = @mutex.synchronize { reading? ? hold(entry) : entry }
81
+ deliver(*ready) if ready
85
82
  end
86
83
 
87
84
  def send_trace(payload, &submit)
88
85
  refresh
89
- held = @mutex.synchronize do
86
+ entry = [payload, nil, submit]
87
+ ready = @mutex.synchronize do
90
88
  id = trace_id(payload)
91
- if !disabled? && !@stopped && id && (@draining.include?(id) || @held.any? { |entry| entry[1] && trace_id(entry[0]) == id })
92
- hold([payload, nil, submit])
93
- true
89
+ if !disabled? && !@stopped && id && (@draining.include?(id) || @held.any? { |held| held[1] && trace_id(held[0]) == id })
90
+ hold(entry)
91
+ else
92
+ entry
94
93
  end
95
94
  end
96
- submit.call(payload) unless held
95
+ deliver(*ready) if ready
97
96
  end
98
97
 
99
98
  def release(timeout)
@@ -114,10 +113,48 @@ module Bitfab
114
113
 
115
114
  def stop
116
115
  reset_after_fork
117
- @mutex.synchronize do
116
+ queued = @mutex.synchronize do
118
117
  @stopped = true
119
- @held.clear
120
118
  @condition.broadcast
119
+ entries, @held = @held, []
120
+ entries
121
+ end
122
+ queued.each { |entry| deliver_safely(*entry) }
123
+ @mutex.synchronize { @held.empty? && @draining.empty? }
124
+ end
125
+
126
+ def content_off?(key, name)
127
+ reset_after_fork
128
+ return false if disabled? || @stopped
129
+
130
+ @mutex.synchronize { @content_off&.fetch(key, nil)&.include?(name) == true }
131
+ end
132
+
133
+ def content_off_names?(key)
134
+ reset_after_fork
135
+ return false if disabled? || @stopped
136
+
137
+ @mutex.synchronize { @content_off&.fetch(key, nil)&.any? == true }
138
+ end
139
+
140
+ def unavailable?
141
+ reset_after_fork
142
+ return false if disabled?
143
+
144
+ @mutex.synchronize { @content_off.nil? && !reading? }
145
+ end
146
+
147
+ def wait_for_first_read(timeout = READ_TIMEOUT)
148
+ reset_after_fork
149
+ return if disabled?
150
+
151
+ deadline = now + [timeout, 0].max
152
+ @mutex.synchronize do
153
+ while reading? && @thread
154
+ remaining = deadline - now
155
+ break unless remaining.positive?
156
+ @condition.wait(@mutex, remaining)
157
+ end
121
158
  end
122
159
  end
123
160
 
@@ -128,9 +165,7 @@ module Bitfab
128
165
  data = payload.dig("rawSpan", "span_data")
129
166
  return payload unless data.is_a?(Hash) && names&.include?(data["name"])
130
167
 
131
- kept = data.except(*CONTENT_KEYS)
132
- kept["content_off_by_simulation_plan"] = true
133
- payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => kept))
168
+ without_content(payload, data)
134
169
  end
135
170
 
136
171
  private
@@ -139,6 +174,10 @@ module Bitfab
139
174
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
140
175
  end
141
176
 
177
+ def reading?
178
+ @content_off.nil? && !@first_read_finished && !@stopped
179
+ end
180
+
142
181
  def trace_id(payload)
143
182
  payload["traceId"] || payload["sourceTraceId"] || payload.dig("externalTrace", "id") || payload["id"]
144
183
  end
@@ -147,19 +186,53 @@ module Bitfab
147
186
  FRAMEWORKS.include?(payload.dig("rawSpan", "span_origin", "instrumentation", "name"))
148
187
  end
149
188
 
189
+ def root?(payload)
190
+ payload.dig("rawSpan", "parent_id").nil?
191
+ end
192
+
150
193
  def hold(entry)
151
194
  @held << entry
152
- if @held.length > MAX_HELD
153
- @held.shift
154
- Bitfab.warn_once("sim-plan-held-overflow", "Simulation plan has not loaded; dropping oldest records beyond #{MAX_HELD}")
155
- end
156
195
  start_reader([@refresh_after - now, 0].max) if @thread.nil? && !@stopped && !disabled?
196
+ @held.shift if @held.length > MAX_HELD
197
+ end
198
+
199
+ def deliver(payload, key, submit)
200
+ submit.call(prepare(payload, key))
201
+ end
202
+
203
+ def deliver_safely(payload, key, submit)
204
+ deliver(payload, key, submit)
205
+ rescue => error
206
+ Bitfab.warn_once("sim-plan-held-span-dropped", "Held record could not be sent: #{error}")
207
+ end
208
+
209
+ def prepare(payload, key)
210
+ return payload if key.nil? || disabled?
211
+ return apply(payload, key) if @mutex.synchronize { @content_off }
212
+
213
+ without_unread_content(payload)
214
+ end
215
+
216
+ def without_unread_content(payload)
217
+ data = payload.dig("rawSpan", "span_data")
218
+ return payload if !data.is_a?(Hash) || root?(payload) || framework?(payload)
219
+
220
+ Bitfab.warn_once("sim-plan-unread-content",
221
+ "Sending spans without inputs and outputs because the simulation plan could not be read")
222
+ without_content(payload, data)
223
+ end
224
+
225
+ def without_content(payload, data)
226
+ kept = data.except(*CONTENT_KEYS)
227
+ kept["content_off_by_simulation_plan"] = true
228
+ payload.merge("rawSpan" => payload["rawSpan"].merge("span_data" => kept))
157
229
  end
158
230
 
159
231
  def start_reader(delay)
160
232
  @thread = Thread.new { read_until_loaded(delay) }
161
233
  @thread.name = "bitfab-sim-plan"
162
234
  rescue ThreadError
235
+ @first_read_finished = true
163
236
  @refresh_after = now + RETRY_SECONDS
164
237
  end
165
238
 
@@ -175,7 +248,7 @@ module Bitfab
175
248
  def read_once
176
249
  body = @reader.get_simulation_plan
177
250
  unless body.is_a?(Hash) && body["nodes"].is_a?(Array)
178
- Bitfab.warn_once("sim-plan-unreadable", "Simulation plan response was not understood; holding content until it loads")
251
+ Bitfab.warn_once("sim-plan-unreadable", "Simulation plan response was not understood; sending spans without inputs and outputs until it loads")
179
252
  return
180
253
  end
181
254
  names = {}
@@ -187,49 +260,35 @@ module Bitfab
187
260
  rescue => error
188
261
  return {} if error.respond_to?(:response) && error.response&.code.to_i == 404
189
262
 
190
- Bitfab.warn_once("sim-plan-unavailable", "Could not read simulation plan: #{error}; holding content until it loads")
263
+ Bitfab.warn_once("sim-plan-unavailable", "Could not read simulation plan: #{error}; sending spans without inputs and outputs until it loads")
191
264
  nil
192
265
  end
193
266
 
194
267
  def read_until_loaded(delay)
195
268
  pause(delay) if delay.positive?
196
- loop do
197
- parsed = (disabled? || @stopped) ? nil : read_once
198
- finished, entries = @mutex.synchronize do
199
- if parsed
200
- @content_off = parsed
201
- @refresh_after = now + REFRESH_SECONDS
202
- queued, @held = @held, []
203
- @draining = queued.filter_map { |payload, key, _| trace_id(payload) if key }.to_set
204
- [true, queued]
205
- elsif @content_off || disabled? || @stopped || @held.empty?
206
- @refresh_after = now + RETRY_SECONDS
207
- [true, []]
208
- else
209
- [false, []]
210
- end
211
- end
212
- if finished
213
- until entries.empty?
214
- entries.each do |payload, key, submit|
215
- submit.call(key ? apply(payload, key) : payload)
216
- rescue => error
217
- Bitfab.warn_once("sim-plan-held-span-dropped", "Held record could not be sent: #{error}")
218
- end
219
- entries = @mutex.synchronize do
220
- queued, @held = @held, []
221
- @draining.clear if queued.empty?
222
- queued
223
- end
224
- end
225
- return
269
+ parsed = (disabled? || @stopped) ? nil : read_once
270
+ entries = @mutex.synchronize do
271
+ @first_read_finished = true
272
+ @content_off = parsed if parsed
273
+ @refresh_after = now + (parsed ? REFRESH_SECONDS : RETRY_SECONDS)
274
+ queued, @held = @held, []
275
+ @draining = queued.filter_map { |payload, key, _| trace_id(payload) if key }.to_set
276
+ @condition.broadcast
277
+ queued
278
+ end
279
+ until entries.empty?
280
+ entries.each { |entry| deliver_safely(*entry) }
281
+ entries = @mutex.synchronize do
282
+ queued, @held = @held, []
283
+ @draining.clear if queued.empty?
284
+ queued
226
285
  end
227
- pause(RETRY_SECONDS)
228
286
  end
229
287
  ensure
230
288
  @mutex.synchronize do
231
289
  @draining.clear
232
290
  @thread = nil
291
+ @first_read_finished = true
233
292
  @condition.broadcast
234
293
  end
235
294
  end
@@ -10,7 +10,8 @@ module Bitfab
10
10
 
11
11
  module Subtree
12
12
  DEFAULT_MAX_DEPTH = 30
13
- DEFAULT_MAX_SPANS = 500
13
+ DEFAULT_MAX_SPANS = 2048
14
+ DEFAULT_MAX_CAPTURED_SUBTREE_SPANS = 512
14
15
  SESSION_KEY = :bitfab_subtree_session
15
16
  DISPATCHER_KEY = :bitfab_subtree_dispatcher
16
17
  FIBER_CONTEXT_KEY = :bitfab_subtree_fiber_context
@@ -98,6 +99,10 @@ module Bitfab
98
99
  @owner = @records.find { |_, entry, _| entry }&.first unless nested_trace_key
99
100
  end
100
101
 
102
+ def recorded_elsewhere?
103
+ @records.any? { |session, entry, _| entry && !session.equal?(@owner) }
104
+ end
105
+
101
106
  def start(trace_id:, span_id:)
102
107
  @records.each do |session, entry, _|
103
108
  next unless entry
@@ -170,7 +175,7 @@ module Bitfab
170
175
  end
171
176
 
172
177
  def capture(client:, trace_function_key:, config:, max_depth:, max_spans:, include_wrappers:,
173
- root_receiver:, root_method_name:, mock_on_replay_default: false)
178
+ root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
174
179
  return yield unless config.root
175
180
 
176
181
  span = SpanContext.current || ReplayContext.current_span
@@ -188,6 +193,7 @@ module Bitfab
188
193
  config:,
189
194
  max_depth:,
190
195
  max_spans:,
196
+ max_captured_subtree_spans:,
191
197
  include_wrappers:,
192
198
  mock_on_replay_default:,
193
199
  root_receiver:,
@@ -412,7 +418,7 @@ module Bitfab
412
418
 
413
419
  def initialize(client:, trace_function_key:, trace_id:, root_span_id:, test_run_id:,
414
420
  input_source_span_id:, config:, max_depth:, max_spans:, include_wrappers:,
415
- root_receiver:, root_method_name:, mock_on_replay_default: false)
421
+ root_receiver:, root_method_name:, max_captured_subtree_spans: DEFAULT_MAX_CAPTURED_SUBTREE_SPANS, mock_on_replay_default: false)
416
422
  @mock_on_replay_default = mock_on_replay_default
417
423
  @client = client
418
424
  @trace_function_key = trace_function_key
@@ -427,6 +433,7 @@ module Bitfab
427
433
  @skip_root_call = true
428
434
  @max_depth = max_depth
429
435
  @max_spans = max_spans
436
+ @max_captured_subtree_spans = [max_captured_subtree_spans, max_spans].min
430
437
  @exclude = config.exclude
431
438
  @include_wrappers = include_wrappers
432
439
  @stacks = {}
@@ -435,7 +442,8 @@ module Bitfab
435
442
  @suppressed_depths = {}
436
443
  @owned_paths = {}
437
444
  @method_metadata = Hash.new { |owners, owner| owners[owner] = {} }
438
- @spans_used = 0
445
+ @spans_used = 1
446
+ @captured_subtree_count = 0
439
447
  @truncated = false
440
448
  @exhausted = false
441
449
  @drained = false
@@ -518,14 +526,11 @@ module Bitfab
518
526
  @truncated = true
519
527
  return false
520
528
  end
521
- if @spans_used >= @max_spans
522
- @truncated = true
523
- @exhausted = true
524
- return false
525
- end
529
+ return true if take_slot(discovered: false)
526
530
 
527
- @spans_used += 1
528
- true
531
+ @truncated = true
532
+ @exhausted = true
533
+ false
529
534
  end
530
535
 
531
536
  def configured_entry(name:, type:, args:, kwargs:, test_run_id:)
@@ -637,14 +642,10 @@ module Bitfab
637
642
  suppress(fiber_id)
638
643
  return
639
644
  end
640
- if @spans_used >= @max_spans
645
+ content_off = content_off?(metadata.name)
646
+ if (limit = limit_reached(discovered: true, content_off:))
641
647
  @truncated = true
642
- @exhausted = true
643
- if captured_calls_drained?
644
- @drained = true
645
- else
646
- suppress(fiber_id)
647
- end
648
+ stop_discovery(fiber_id) unless limit == :max_captured_subtree_spans && content_off_names?
648
649
  return
649
650
  end
650
651
 
@@ -655,9 +656,10 @@ module Bitfab
655
656
  return
656
657
  end
657
658
 
658
- @spans_used += 1
659
+ count_span(discovered: true, content_off:)
659
660
  span_id = SecureRandom.uuid
660
- args, kwargs = inputs(event)
661
+ without_content = content_off || plan_unavailable?
662
+ args, kwargs = without_content ? [[], {}] : inputs(event)
661
663
  # A nested subtree root temporarily owns the ambient span context, but
662
664
  # an outer session must never parent its spans across trace boundaries.
663
665
  active_parent_span_id = if active_parent&.dig(:trace_id) == @trace_id
@@ -671,7 +673,8 @@ module Bitfab
671
673
  name: metadata.name,
672
674
  args:,
673
675
  kwargs:,
674
- started_at: now
676
+ started_at: now,
677
+ span_data: without_content ? {"content_off_by_simulation_plan" => true} : nil
675
678
  )
676
679
  end
677
680
 
@@ -878,14 +881,60 @@ module Bitfab
878
881
  @suppressed_depths[fiber_id] = 1
879
882
  end
880
883
 
884
+ def limit_reached(discovered:, content_off: false)
885
+ return :max_spans if @spans_used >= @max_spans
886
+
887
+ :max_captured_subtree_spans if discovered && !content_off && @captured_subtree_count >= @max_captured_subtree_spans
888
+ end
889
+
890
+ def take_slot(discovered:)
891
+ return false if limit_reached(discovered:)
892
+
893
+ count_span(discovered:, content_off: false)
894
+ true
895
+ end
896
+
897
+ def count_span(discovered:, content_off:)
898
+ @spans_used += 1
899
+ @captured_subtree_count += 1 if discovered && !content_off
900
+ end
901
+
902
+ def stop_discovery(fiber_id)
903
+ @exhausted = true
904
+ if captured_calls_drained?
905
+ @drained = true
906
+ else
907
+ suppress(fiber_id)
908
+ end
909
+ end
910
+
911
+ def content_off?(name)
912
+ @client.send(:content_off_by_simulation_plan?, trace_id: @trace_id, trace_function_key: @trace_function_key,
913
+ span_name: name, instrumentation: "trace")
914
+ rescue
915
+ false
916
+ end
917
+
918
+ def content_off_names?
919
+ @client.send(:simulation_plan_content_off_names?, trace_id: @trace_id, trace_function_key: @trace_function_key)
920
+ rescue
921
+ false
922
+ end
923
+
924
+ def plan_unavailable?
925
+ @client.send(:simulation_plan_unavailable?, instrumentation: "trace")
926
+ rescue
927
+ false
928
+ end
929
+
881
930
  def warn_if_truncated
882
931
  return unless @truncated
883
932
 
884
933
  Bitfab.warn_once(
885
934
  "subtree-truncated:#{@trace_function_key}",
886
935
  "'#{@trace_function_key}' hit a subtree capture limit (max_depth=#{@max_depth}, " \
887
- "max_spans=#{@max_spans}) and its trace is incomplete. Raise the limits, or narrow " \
888
- "what is traced with exclude: [...]."
936
+ "max_captured_subtree_spans=#{@max_captured_subtree_spans}, max_spans=#{@max_spans}) and its trace is incomplete. " \
937
+ "Raise the limits, add bitfab_node to the calls that matter, or narrow what is traced with exclude: [...]."
889
938
  )
890
939
  end
891
940
 
@@ -83,7 +83,9 @@ module Bitfab
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
85
  max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
86
+ max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
86
87
  exclude: [], include_wrappers: false, mock_on_replay_default: false, client: nil)
88
+ validate_trace_options!(max_spans:)
87
89
  capture_config = Subtree.prepare(root_source: source_of(klass, method_name), exclude:)
88
90
  method_name_str = method_name.to_s
89
91
  bound_client = client
@@ -95,6 +97,8 @@ module Bitfab
95
97
  Subtree.assert_surface!(:trace)
96
98
  next super(*args, **kwargs, &block) if Subtree.nested_root_suppressed?
97
99
 
100
+ target_client.send(:wait_for_simulation_plan) unless Subtree.current_session
101
+
98
102
  span_name = name || Subtree.qualified_name(self, method_name)
99
103
  managed = SpanContext.current&.dig(:recording_override)
100
104
  if managed && managed.empty? && (ReplayContext.current || SeedContext.current)
@@ -102,7 +106,7 @@ module Bitfab
102
106
  SpanContext.current[:surface] = :trace
103
107
  next Subtree.capture(
104
108
  client: target_client, trace_function_key:, config: capture_config,
105
- max_depth:, max_spans:, include_wrappers:, mock_on_replay_default:,
109
+ max_depth:, max_spans:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
106
110
  root_receiver: self, root_method_name: method_name
107
111
  ) { super(*args, **kwargs, &block) }
108
112
  end
@@ -116,7 +120,7 @@ module Bitfab
116
120
  around_body: call.method(:run)) do
117
121
  Subtree.capture(
118
122
  client: target_client, trace_function_key:, config: capture_config,
119
- max_depth:, max_spans:, include_wrappers:, mock_on_replay_default:,
123
+ max_depth:, max_spans:, max_captured_subtree_spans:, include_wrappers:, mock_on_replay_default:,
120
124
  root_receiver: self, root_method_name: method_name
121
125
  ) { super(*args, **kwargs, &block) }
122
126
  end
@@ -161,7 +165,7 @@ module Bitfab
161
165
  span_name:, span_type: type, function_name: method_name_str,
162
166
  args:, kwargs:, capture_when: "nested", mock_on_replay: policy,
163
167
  explicit_span_receiver: self, explicit_span_method_name: method_name,
164
- test_run_id:, finalize:, surface: :trace,
168
+ test_run_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?,
165
169
  on_start: call.method(:start), on_complete: call.method(:complete),
166
170
  around_body: call.method(:run)) { super(*args, **kwargs, &block) }
167
171
  end
@@ -189,6 +193,14 @@ module Bitfab
189
193
  end
190
194
  private_class_method :preserve_visibility
191
195
 
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
+
192
204
  def self.validate_node_options!(capture:, mock_on_replay:)
193
205
  return unless !capture && mock_on_replay
194
206
 
@@ -293,7 +305,9 @@ module Bitfab
293
305
  # Descendants require no Bitfab declarations and record as function spans.
294
306
  def bitfab_trace(method_name, trace_function_key: nil, name: nil, type: "custom",
295
307
  max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
308
+ max_captured_subtree_spans: Subtree::DEFAULT_MAX_CAPTURED_SUBTREE_SPANS,
296
309
  exclude: [], include_wrappers: false, mock_on_replay_default: false)
310
+ Traceable.send(:validate_trace_options!, max_spans:)
297
311
  trace_function_key ||= @bitfab_function_key
298
312
  unless trace_function_key
299
313
  raise "No trace function key provided. Pass `trace_function_key:` to `bitfab_trace` " \
@@ -306,6 +320,7 @@ module Bitfab
306
320
  type:,
307
321
  max_depth:,
308
322
  max_spans:,
323
+ max_captured_subtree_spans:,
309
324
  exclude:,
310
325
  include_wrappers:,
311
326
  mock_on_replay_default:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.51.10"
4
+ VERSION = "0.57.0"
5
5
  end
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.51.10
4
+ version: 0.57.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team