bitfab 0.51.2 → 0.51.3

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: ea799ee55cf7c1ac7167300d800d9593162ddc6b906557c12c3079607fcfc4f4
4
- data.tar.gz: f7985925a6a8b3cf8c5e2ea4389bd8122646f5bf2eb5bf59e6769d9f533636b1
3
+ metadata.gz: 571e505294f7f5abe3b1d4c8521c4427cf30c105e03eb87509e70dd929559f43
4
+ data.tar.gz: 2a39fa504e22027f19a845efb156fb6a7c38d4a1187b7df64dad1623b5cccb4b
5
5
  SHA512:
6
- metadata.gz: 10c67dc35cf10ed1c445108ce1801993b8f4115afda9411fe1dc3b9e12213ab090441e91c6a59a0864925822a09d344360e4d10ab15cac0a1a183c56d007972e
7
- data.tar.gz: 2f968a4c58b66fd51d49c74199b20207a44aecd56285656b2ece8df04a76e9070b9f87c7dcbb53f2eeedfe61ba0a271d68660b934b4a56eb1f8fca278221f452
6
+ metadata.gz: e4146889a3a1f02b3bca7a104810067d59ec1e138cd099dc4822d241bcf34a6cb1514535d0bb902628e18a990bb31b5b1c79074cb946bdae7416bad3121a877c
7
+ data.tar.gz: ab9ab7427fb6065d4a6c1748b780a60d12b70302971b474927741653da400f60deb8a739addb5eb60ef79e7e9c791922538c86cbe53f163a1eb69c4dd175b87b
data/README.md CHANGED
@@ -215,6 +215,51 @@ end
215
215
  # └─ check_fraud (grandchild)
216
216
  ```
217
217
 
218
+ ### Experimental Subtree Tracing
219
+
220
+ Use `bitfab_trace` to record a root method and every first-party Ruby method it calls without wrapping each descendant:
221
+
222
+ ```ruby
223
+ class OrderPipeline
224
+ include Bitfab::Traceable
225
+ bitfab_function "order-pipeline"
226
+
227
+ bitfab_trace :process, type: "agent"
228
+ def process(order_id)
229
+ validate(order_id)
230
+ end
231
+
232
+ def validate(order_id)
233
+ { order_id:, valid: true }
234
+ end
235
+ end
236
+ ```
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_span` when replay must substitute it: its explicit span records once, and automatic descendants continue beneath it.
239
+
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
+
242
+ Returning an `Enumerator` keeps capture pending until iteration finishes or raises, including calls already paused in child Fibers. Capture is inactive between the initial return and consumption. Automatic spans record lazy return values as placeholders without iterating them; the traced root records the yielded values.
243
+
244
+ A nested `bitfab_trace` with a different key starts an independent trace while the outer trace continues recording the overlapping subtree. Both traces have distinct trace and span IDs, including during replay, and the complete structure each root would record alone, so two active roots double span volume in their shared region. A recursive call or nested annotation using the same key stays in the active trace instead of creating another root.
245
+
246
+ Configure a discovered method with `bitfab_node`. A node never creates a span or trace by itself; the active `bitfab_trace` owns its key, limits, parent, and lifecycle:
247
+
248
+ ```ruby
249
+ bitfab_node :summarize,
250
+ name: "Model call",
251
+ type: "llm",
252
+ mock_on_replay: true,
253
+ finalize: ->(response) { {text: response.text} }
254
+ def summarize(order)
255
+ model.generate(order)
256
+ end
257
+ ```
258
+
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
+
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.
262
+
218
263
  ### Input/Output Capture
219
264
 
220
265
  Positional and keyword arguments are automatically captured:
@@ -427,6 +472,10 @@ See [bitfab-ruby-example/](../bitfab-ruby-example/) for complete working example
427
472
 
428
473
  - [test_span.rb](../bitfab-ruby-example/scripts/test_span.rb) - Basic span creation
429
474
  - [test_nested_spans.rb](../bitfab-ruby-example/scripts/test_nested_spans.rb) - Nested span hierarchies
475
+ - [test_subtree.rb](../bitfab-ruby-example/scripts/test_subtree.rb) - Whole-subtree capture with independent overlapping roots
476
+ - [test_subtree_comprehensive.rb](../bitfab-ruby-example/scripts/test_subtree_comprehensive.rb) - Self-checking subtree and node behavior examples, including child-Fiber context and installed application gems
477
+ - [test_subtree_replay.rb](../bitfab-ruby-example/scripts/test_subtree_replay.rb) - Live subtree record and replay
478
+ - [subtree coverage map](../bitfab-ruby-example/subtree_examples/README.md) - Every subtree spec behavior mapped to its runnable example
430
479
  - [test_wrap.rb](../bitfab-ruby-example/scripts/test_wrap.rb) - Wrapping external code
431
480
  - [test_enabled.rb](../bitfab-ruby-example/scripts/test_enabled.rb) - Using the enabled flag
432
481
  - [test_metadata.rb](../bitfab-ruby-example/scripts/test_metadata.rb) - Custom metadata
data/lib/bitfab/client.rb CHANGED
@@ -7,6 +7,7 @@ require_relative "constants"
7
7
  require_relative "http_client"
8
8
  require_relative "replay"
9
9
  require_relative "span_context"
10
+ require_relative "subtree"
10
11
  require_relative "serialize"
11
12
  require_relative "timestamp"
12
13
  require_relative "warn_once"
@@ -303,6 +304,23 @@ module Bitfab
303
304
  BitfabFunction.new(self, trace_function_key)
304
305
  end
305
306
 
307
+ # Configure an existing method only when it is discovered beneath a
308
+ # bitfab_trace root owned by this client.
309
+ def node(klass, method_name, name: nil, type: "custom", capture: true,
310
+ test_run_id: nil, mock_on_replay: false, finalize: nil)
311
+ Bitfab::Traceable.node(
312
+ klass,
313
+ method_name,
314
+ name:,
315
+ type:,
316
+ capture:,
317
+ test_run_id:,
318
+ mock_on_replay:,
319
+ finalize:,
320
+ client: self
321
+ )
322
+ end
323
+
306
324
  # Fetch one persisted span without loading the full trace.
307
325
  # Exactly one of id or name is required. Name lookups return the last
308
326
  # matching span by default; occurrence also accepts "first" or a zero-based
@@ -327,7 +345,8 @@ module Bitfab
327
345
  # Execute a block inside a span context, sending trace data on completion.
328
346
  # Called by Traceable, not intended for direct use.
329
347
  def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
330
- capture_when: "always", mock_on_replay: false)
348
+ capture_when: "always", mock_on_replay: false, independent_root: false,
349
+ explicit_span_receiver: nil, explicit_span_method_name: nil, test_run_id: nil, finalize: nil)
331
350
  # Decide at CALL time, not construction. The key may be set after the
332
351
  # client is built (env loaded later), so re-checking per call lets a
333
352
  # late-resolved key take effect.
@@ -359,13 +378,24 @@ module Bitfab
359
378
  resolved_input_source_span_id = nil
360
379
  begin
361
380
  replay_ctx = ReplayContext.current
362
- parent = SpanContext.current || ReplayContext.current_span
363
- trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
381
+ # A subtree annotation declares a trace-function root, even when it is
382
+ # invoked inside another span. Outer subtree sessions keep observing it
383
+ # through TracePoint instead of owning this root span.
384
+ ambient_parent = SpanContext.current || ReplayContext.current_span
385
+ parent = independent_root ? nil : ambient_parent
386
+ if parent && (subtree_parent_span_id = Subtree.current_parent_span_id(parent[:trace_id]))
387
+ parent = parent.merge(span_id: subtree_parent_span_id)
388
+ end
389
+ trace_id = if independent_root && ambient_parent
390
+ SecureRandom.uuid
391
+ else
392
+ parent&.dig(:trace_id) || replay_ctx&.dig(:trace_id) || SecureRandom.uuid
393
+ end
364
394
  span_id = SecureRandom.uuid
365
395
  parent_span_id = parent&.dig(:span_id)
366
396
  is_root_span = parent_span_id.nil?
367
397
  started_at = Bitfab.now_iso_timestamp
368
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
398
+ resolved_test_run_id = replay_ctx ? replay_ctx[:test_run_id] : test_run_id
369
399
  resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
370
400
  resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
371
401
 
@@ -439,18 +469,28 @@ module Bitfab
439
469
  error = nil
440
470
  span_contexts = nil
441
471
  span_prompt = nil
442
- finalized = false
472
+ completed = false
443
473
 
444
- finalize = lambda do |final_result, final_error|
474
+ complete = lambda do |final_result, final_error|
445
475
  # Never crash the host app due to span building/sending. Idempotent:
446
476
  # only the first call sends the span. Subsequent calls (e.g. from the
447
477
  # enumerator wrapper after iteration completes) are no-ops.
448
- next if finalized
478
+ next if completed
449
479
 
450
- finalized = true
480
+ completed = true
451
481
 
452
482
  begin
453
483
  ended_at = Bitfab.now_iso_timestamp
484
+ recorded_result = final_result
485
+ recorded_error = final_error
486
+ if finalize && final_error.nil?
487
+ begin
488
+ recorded_result = finalize.call(final_result)
489
+ rescue => e
490
+ recorded_result = nil
491
+ recorded_error = "finalize failed: #{e.message}"
492
+ end
493
+ end
454
494
 
455
495
  send_span(
456
496
  trace_function_key:,
@@ -464,8 +504,8 @@ module Bitfab
464
504
  prompt: span_prompt,
465
505
  args:,
466
506
  kwargs:,
467
- result: final_result,
468
- error: final_error,
507
+ result: recorded_result,
508
+ error: recorded_error,
469
509
  started_at:,
470
510
  ended_at:,
471
511
  test_run_id: resolved_test_run_id,
@@ -514,8 +554,22 @@ module Bitfab
514
554
 
515
555
  begin
516
556
  ReplayContext.with_span(trace_id:, span_id:) do
517
- SpanContext.with_span(trace_id:, span_id:) do
518
- result = yield
557
+ SpanContext.with_span(
558
+ trace_id:,
559
+ span_id:,
560
+ explicit_span_receiver:,
561
+ explicit_span_method_name: explicit_span_method_name&.to_sym
562
+ ) do
563
+ result = if explicit_span_receiver
564
+ Subtree.with_borrowed_span(
565
+ trace_id:,
566
+ span_id:,
567
+ receiver: explicit_span_receiver,
568
+ method_name: explicit_span_method_name
569
+ ) { yield }
570
+ else
571
+ yield
572
+ end
519
573
  ensure
520
574
  # Capture contexts before the span context is popped
521
575
  span_contexts = SpanContext.current&.dig(:contexts)
@@ -524,7 +578,7 @@ module Bitfab
524
578
  end
525
579
  rescue => e
526
580
  error = e.message
527
- finalize.call(result, error)
581
+ complete.call(result, error)
528
582
  raise
529
583
  end
530
584
 
@@ -542,10 +596,17 @@ module Bitfab
542
596
  # once iteration completes (or errors).
543
597
  #
544
598
  if result.is_a?(Enumerator)
545
- return wrap_enumerator(result, trace_id:, span_id:, finalize:)
599
+ return wrap_enumerator(
600
+ result,
601
+ trace_id:,
602
+ span_id:,
603
+ complete:,
604
+ explicit_span_receiver:,
605
+ explicit_span_method_name:
606
+ )
546
607
  end
547
608
 
548
- finalize.call(result, error)
609
+ complete.call(result, error)
549
610
  result
550
611
  end
551
612
 
@@ -606,8 +667,13 @@ module Bitfab
606
667
  # callbacks nest under the parent span. Yielded values are collected as the
607
668
  # span output. The span is sent exactly once: when iteration finishes,
608
669
  # raises, or the wrapper is `.close`d.
609
- def wrap_enumerator(source, trace_id:, span_id:, finalize:)
670
+ def wrap_enumerator(source, trace_id:, span_id:, complete:, explicit_span_receiver: nil,
671
+ explicit_span_method_name: nil)
610
672
  span_entry = {trace_id:, span_id:}
673
+ if explicit_span_receiver
674
+ span_entry[:explicit_span_receiver] = explicit_span_receiver
675
+ span_entry[:explicit_span_method_name] = explicit_span_method_name&.to_sym
676
+ end
611
677
  yielded = []
612
678
 
613
679
  Enumerator.new do |yielder|
@@ -619,13 +685,26 @@ module Bitfab
619
685
  SpanContext.with_fiber_bridge(trace_id:, span_id:) do
620
686
  SpanContext.stack.push(span_entry)
621
687
  begin
622
- source.each do |value|
623
- yielded << value
624
- yielder << value
688
+ iterate = lambda do
689
+ source.each do |value|
690
+ yielded << value
691
+ yielder << value
692
+ end
693
+ end
694
+ if explicit_span_receiver
695
+ Subtree.with_borrowed_span(
696
+ trace_id:,
697
+ span_id:,
698
+ receiver: explicit_span_receiver,
699
+ method_name: explicit_span_method_name,
700
+ &iterate
701
+ )
702
+ else
703
+ iterate.call
625
704
  end
626
- finalize.call(yielded, nil)
705
+ complete.call(yielded, nil)
627
706
  rescue => e
628
- finalize.call(yielded, e.message)
707
+ complete.call(yielded, e.message)
629
708
  raise
630
709
  ensure
631
710
  SpanContext.stack.pop
@@ -1009,5 +1088,22 @@ module Bitfab
1009
1088
  client: @client
1010
1089
  )
1011
1090
  end
1091
+
1092
+ # Experimentally trace an existing method and its first-party call subtree.
1093
+ def trace(klass, method_name, name: nil, type: "custom",
1094
+ max_depth: Subtree::DEFAULT_MAX_DEPTH, max_spans: Subtree::DEFAULT_MAX_SPANS,
1095
+ exclude: [], include_wrappers: false)
1096
+ Bitfab::Traceable.trace(
1097
+ klass, method_name,
1098
+ trace_function_key: @trace_function_key,
1099
+ name:,
1100
+ type:,
1101
+ max_depth:,
1102
+ max_spans:,
1103
+ exclude:,
1104
+ include_wrappers:,
1105
+ client: @client
1106
+ )
1107
+ end
1012
1108
  end
1013
1109
  end
@@ -133,7 +133,7 @@ module Bitfab
133
133
  end
134
134
 
135
135
  def current
136
- stack.last || fiber_bridge_stack.last
136
+ stack.last || Subtree.fiber_context&.inherited_span || fiber_bridge_stack.last
137
137
  end
138
138
 
139
139
  # Enumerator.new and enum_for run their source body in another Fiber on
@@ -149,8 +149,12 @@ module Bitfab
149
149
 
150
150
  # Execute a block with a new span pushed onto the stack.
151
151
  # The span is automatically popped when the block completes.
152
- def with_span(trace_id:, span_id:)
152
+ def with_span(trace_id:, span_id:, explicit_span_receiver: nil, explicit_span_method_name: nil)
153
153
  entry = {trace_id:, span_id:}
154
+ if explicit_span_receiver
155
+ entry[:explicit_span_receiver] = explicit_span_receiver
156
+ entry[:explicit_span_method_name] = explicit_span_method_name
157
+ end
154
158
  stack.push(entry)
155
159
  yield
156
160
  ensure