bitfab 0.36.10 → 0.38.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: 47a3e6b763bf08e8e4a25173414f1ed306dc7183ea443f0b71264645fe5b4c02
4
- data.tar.gz: 2c629b4d599e25e124334f1d9ee61b5683316c7b849535ffa0aabe50324105eb
3
+ metadata.gz: e4bb59de5ab0ec989d85e8be14ee08ad935ada5145e01826b34657fc8c9e6623
4
+ data.tar.gz: 78b0255c5a3062470f4149baf632c94a5b18b430567485a1ba58f5b9cf9b9534
5
5
  SHA512:
6
- metadata.gz: 30dbfa2213bd23581fa3387d47a600ad79ed62f8120ae11e237557b87aa28f8d1413162b0b9ab9f9ddc9d5489a0b884793e632a29538369e40753327384d6bf2
7
- data.tar.gz: 7cb61050f45cc07f273f8bcc22f8ca8d0530d9b67386b22472906225eedc8d5937e2f8158a7810b0b211a1c0a558994c0c675f8e17a8efa633a882cc0d8fd596
6
+ metadata.gz: e127f198a39372bebaff5a0cf395b4b6b00f2ede4bf9768b8e3a4556ee00271ddf44fe4a40f16fcffac72d3def4d3c83c3f756801ba156edbe58ecbfd189f2e5
7
+ data.tar.gz: ca803b3df3783f6bb01d5eb5a518300334d0ccd4f31d43f3870c981779cd69710d076a1ded3f058c3b9e8aa65f643b3872dd60f99ac8e4394cd5f97855ec1970
data/README.md CHANGED
@@ -301,7 +301,7 @@ client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "marked")
301
301
  # "none": everything runs real code
302
302
  client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "none")
303
303
 
304
- # "all": every child span returns its historical output
304
+ # "all": every matched recorded child returns history; missing occurrences fail closed
305
305
  client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "all")
306
306
  ```
307
307
 
@@ -331,7 +331,7 @@ class Pipeline
331
331
  end
332
332
  ```
333
333
 
334
- Use the default `mock: "marked"` behavior when you want to iterate on `process`'s logic without paying for the LLM call each run. Use `mock: "all"` for the cheapest possible replay (every child span returns its recorded output).
334
+ Use the default `mock: "marked"` behavior when you want to iterate on `process`'s logic without paying for the LLM call each run. Use `mock: "all"` for the cheapest possible replay (every matched recorded child span returns its recorded output; a missing occurrence fails the item closed).
335
335
 
336
336
  ### Error Handling
337
337
 
data/lib/bitfab/client.rb CHANGED
@@ -16,7 +16,7 @@ module Bitfab
16
16
  UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
17
17
 
18
18
  # Sentinel returned by check_mock_replay when this span should run real
19
- # code (no mock active, wrong strategy, or no matching historical entry).
19
+ # code (no mock active or the base strategy did not select it).
20
20
  # Using a sentinel rather than nil/false avoids confusing legitimate mocked
21
21
  # outputs (which may themselves be nil or false).
22
22
  MOCK_REPLAY_MISS = Object.new.freeze
@@ -87,9 +87,10 @@ module Bitfab
87
87
  # @param receiver [Object, Class] an instance for instance methods, or a Class for class methods
88
88
  # @param method_name [Symbol] the method to replay
89
89
  # @param trace_function_key [String] the trace function key for this method
90
- # @param limit [Integer, nil] maximum number of traces to replay (default: 5).
91
- # Mutually exclusive with trace_ids: an explicit ID list already
92
- # determines how many traces replay, so passing both raises.
90
+ # @param limit [Integer, nil] maximum number of traces to replay (default: 5;
91
+ # maximum: 5,000). Ignored when trace_ids or dataset_id is passed because
92
+ # either source already determines how many traces replay. Supplying
93
+ # trace_ids also emits a warning.
93
94
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
94
95
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
95
96
  # @param code_change_description [String, nil] optional rationale for the
@@ -111,7 +112,9 @@ module Bitfab
111
112
  # same org and trace function or the server rejects the replay
112
113
  # @param mock [String] mock strategy for child spans: "marked" (default),
113
114
  # "none", or "all". "marked" only mocks spans declared with
114
- # mock_on_replay: true; "all" mocks every child span.
115
+ # mock_on_replay: true; "all" mocks every matched recorded child span. A selected
116
+ # occurrence that is unavailable errors the item without executing the
117
+ # real child.
115
118
  # @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
116
119
  # onto the method's current signature when its shape changed after the
117
120
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
@@ -122,16 +125,17 @@ module Bitfab
122
125
  # match.call(node) selects spans to substitute (node is
123
126
  # { trace_function_key:, span_name:, type:, original_span_id: }). value is
124
127
  # EITHER a flat value injected directly OR a callable invoked with
125
- # { node:, inputs:, get_original_output: }; its result becomes the span's
126
- # output (full replacement), so downstream real code runs against it. The
128
+ # { node:, inputs:, kwargs:, get_original_output: }; its result becomes
129
+ # the span's output (full replacement), so downstream real code runs against it. The
127
130
  # first matching override wins. Per-call overrides take precedence over
128
131
  # those registered via register_mock_override, and both take precedence
129
132
  # over the base mock strategy. The root span is never overridden.
130
- # @param db_branch [Hash, nil] passing it at all turns database branching
131
- # on, so +{}+ enables it with the mirror project's own sizing. Keys
132
- # +:min_cu+/+:max_cu+ size the branch compute in Neon Compute Units and
133
- # +:warmup_sql+ warms its cache. Read the resolved branch inside the
134
- # replayed method with +Bitfab.current_replay_branch+.
133
+ # @param db_branch [Boolean, Hash, nil] +true+ or a Hash enables database
134
+ # branching; +false+/+nil+ leave it off. An empty Hash uses the mirror
135
+ # project's own sizing. Keys +:min_cu+/+:max_cu+ size the branch compute
136
+ # in Neon Compute Units and +:warmup_sql+ warms its cache. Read the
137
+ # resolved branch inside the replayed method with
138
+ # +Bitfab.current_replay_branch+.
135
139
  # @param on_item_finish [#call, nil] optional callback invoked exactly once
136
140
  # per item as it finishes, always with a running-totals hash containing
137
141
  # { completed:, total:, succeeded:, errored:, item: }. It never receives a
@@ -281,7 +285,7 @@ module Bitfab
281
285
  )
282
286
  resolved_capture_when = "always"
283
287
  end
284
- return yield if resolved_capture_when == "nested" && SpanContext.current.nil?
288
+ return yield if resolved_capture_when == "nested" && SpanContext.current.nil? && ReplayContext.current_span.nil?
285
289
 
286
290
  # Span setup runs before the user's block. Tracing is a side-channel, so
287
291
  # if anything here raises (id generation, trace-state bookkeeping, a
@@ -297,8 +301,8 @@ module Bitfab
297
301
  resolved_test_run_id = nil
298
302
  resolved_input_source_span_id = nil
299
303
  begin
300
- parent = SpanContext.current
301
304
  replay_ctx = ReplayContext.current
305
+ parent = SpanContext.current || ReplayContext.current_span
302
306
  trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
303
307
  span_id = SecureRandom.uuid
304
308
  parent_span_id = parent&.dig(:span_id)
@@ -420,7 +424,12 @@ module Bitfab
420
424
  # Deprecated wire alias, kept so this SDK still reports usage
421
425
  # against servers that predate the rename.
422
426
  source_trace_id: replay_ctx[:source_bitfab_trace_id],
423
- accessed: replay_ctx[:db_snapshot_accessed] == true
427
+ accessed: replay_ctx[:db_snapshot_accessed] == true,
428
+ # Echoed verbatim (camelCase inside) rather than re-cased into
429
+ # this record's snake_case: it is the server's own object
430
+ # coming back, and a translation layer here is one more thing
431
+ # to drift.
432
+ timings: replay_ctx[:db_branch_timings]
424
433
  }
425
434
  end
426
435
 
@@ -440,12 +449,14 @@ module Bitfab
440
449
  end
441
450
 
442
451
  begin
443
- SpanContext.with_span(trace_id:, span_id:) do
444
- result = yield
445
- ensure
446
- # Capture contexts before the span context is popped
447
- span_contexts = SpanContext.current&.dig(:contexts)
448
- span_prompt = SpanContext.current&.dig(:prompt)
452
+ ReplayContext.with_span(trace_id:, span_id:) do
453
+ SpanContext.with_span(trace_id:, span_id:) do
454
+ result = yield
455
+ ensure
456
+ # Capture contexts before the span context is popped
457
+ span_contexts = SpanContext.current&.dig(:contexts)
458
+ span_prompt = SpanContext.current&.dig(:prompt)
459
+ end
449
460
  end
450
461
  rescue => e
451
462
  error = e.message
@@ -466,12 +477,6 @@ module Bitfab
466
477
  # collects yielded values as the span output, and finalizes the span
467
478
  # once iteration completes (or errors).
468
479
  #
469
- # Limitation: when the source enumerator itself runs its body in a
470
- # separate fiber (e.g. `Enumerator.new { |y| ... }` or `enum_for(...)`
471
- # without a block), nested `bitfab_span` calls inside that body fiber
472
- # still see an empty stack because `Thread.current[STACK_KEY]` is
473
- # fiber-local. Lazy chains over collections (`.lazy.map`) and ordinary
474
- # `each` callbacks DO run in the iterating fiber and nest correctly.
475
480
  if result.is_a?(Enumerator)
476
481
  return wrap_enumerator(result, trace_id:, span_id:, finalize:)
477
482
  end
@@ -544,19 +549,24 @@ module Bitfab
544
549
  Enumerator.new do |yielder|
545
550
  # Push our span onto this fiber's stack so anything that runs in this
546
551
  # fiber (including the user's lazy block and `each` callbacks) sees
547
- # the right parent.
548
- SpanContext.stack.push(span_entry)
549
- begin
550
- source.each do |value|
551
- yielded << value
552
- yielder << value
552
+ # the right parent. The fiber bridge carries the same parent into
553
+ # Enumerator.new / enum_for source fibers on this thread.
554
+ ReplayContext.with_span(trace_id:, span_id:) do
555
+ SpanContext.with_fiber_bridge(trace_id:, span_id:) do
556
+ SpanContext.stack.push(span_entry)
557
+ begin
558
+ source.each do |value|
559
+ yielded << value
560
+ yielder << value
561
+ end
562
+ finalize.call(yielded, nil)
563
+ rescue => e
564
+ finalize.call(yielded, e.message)
565
+ raise
566
+ ensure
567
+ SpanContext.stack.pop
568
+ end
553
569
  end
554
- finalize.call(yielded, nil)
555
- rescue => e
556
- finalize.call(yielded, e.message)
557
- raise
558
- ensure
559
- SpanContext.stack.pop
560
570
  end
561
571
  end
562
572
  end
@@ -612,6 +622,9 @@ module Bitfab
612
622
  usage["source_trace_id"] = db_snapshot_usage[:original_trace_id]
613
623
  end
614
624
  usage["accessed"] = db_snapshot_usage[:accessed]
625
+ if db_snapshot_usage[:timings]
626
+ usage["timings"] = db_snapshot_usage[:timings]
627
+ end
615
628
  raw_trace["db_snapshot_usage"] = usage
616
629
  end
617
630
 
@@ -799,7 +812,11 @@ module Bitfab
799
812
  return MOCK_REPLAY_MISS
800
813
  end
801
814
 
802
- return MOCK_REPLAY_MISS unless mock_entry
815
+ unless mock_entry
816
+ raise StandardError,
817
+ "Replay selected span '#{trace_function_key}:#{span_name}' for mocking, " \
818
+ "but recorded occurrence #{call_index + 1} is unavailable. The real span was not executed."
819
+ end
803
820
 
804
821
  [resolve_recorded_output(mock_entry, replay_ctx), "recorded"]
805
822
  end
@@ -902,7 +919,8 @@ module Bitfab
902
919
  # @param capture_when [String, Symbol] "always" to allow a root trace, or
903
920
  # "nested" to capture only when another Bitfab span is active. Unknown
904
921
  # values warn once and default to "always"
905
- # @param mock_on_replay [Boolean] mark this span for the default "marked" mock strategy
922
+ # @param mock_on_replay [Boolean] mark this span for the default "marked"
923
+ # mock strategy. A missing selected occurrence fails closed.
906
924
  def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false)
907
925
  Bitfab::Traceable.wrap(
908
926
  klass, method_name,
@@ -31,10 +31,10 @@ module Bitfab
31
31
  # nil is a legitimate injected value. To inject a proc as the literal
32
32
  # output, wrap it in a callable value: value: ->(ctx) { the_proc }.
33
33
  #
34
- # Unlike the TypeScript SDK, Ruby's span tree is fetched with outputs inline
35
- # (get_span_tree), so get_original_output simply returns the inline recorded
36
- # output. There is no lazy per-span fetch and Ruby is synchronous, so there
37
- # is no async limitation.
34
+ # Unlike the TypeScript SDK, get_original_output is synchronous. Under
35
+ # non-"all" replay the first access may fetch the recorded output lazily;
36
+ # that fetch is memoized for the replay item. Under mock: "all" the output is
37
+ # inline even when overrides are present. Ruby has no async-span limitation.
38
38
  module MockOverride
39
39
  module_function
40
40
 
data/lib/bitfab/replay.rb CHANGED
@@ -41,28 +41,47 @@ module Bitfab
41
41
  # - "marked" : only spans declared with mock_on_replay: true return historical
42
42
  # output; everything else runs real code (default)
43
43
  # - "none" : every child span runs real code
44
- # - "all" : every child span returns its historical output
44
+ # - "all" : every matched recorded child span returns its historical
45
+ # output; a missing occurrence fails the item closed
45
46
  MOCK_STRATEGIES = %w[none all marked].freeze
46
47
 
47
- # Thread-local replay context management.
48
+ # Replay context shared by fibers on the current thread. Ruby's
49
+ # Thread#[] storage is fiber-local, so use true thread variables here: lazy
50
+ # Enumerator bodies run in another fiber but must retain replay mocking.
48
51
  module ReplayContext
49
52
  module_function
50
53
 
51
54
  def current
52
- Thread.current[REPLAY_CONTEXT_KEY]
55
+ Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
53
56
  end
54
57
 
55
- # Execute a block with replay context set on the current thread.
56
- # The context is automatically cleared when the block completes.
58
+ def current_span
59
+ current&.dig(:span_stack)&.last
60
+ end
61
+
62
+ def with_span(trace_id:, span_id:)
63
+ ctx = current
64
+ return yield unless ctx
65
+
66
+ entry = {trace_id:, span_id:}
67
+ ctx[:span_stack].push(entry)
68
+ yield
69
+ ensure
70
+ ctx[:span_stack].pop if ctx
71
+ end
72
+
73
+ # Execute a block with replay context set for every fiber on the current
74
+ # thread. Child threads and processes intentionally do not inherit it.
57
75
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
58
76
  mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
59
- db_branch_lease: nil, source_bitfab_trace_id: nil)
60
- previous = Thread.current[REPLAY_CONTEXT_KEY]
77
+ db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil)
78
+ previous = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
61
79
  ctx = {
62
80
  test_run_id:,
63
81
  input_source_span_id:,
64
82
  input_source_trace_id:,
65
- trace_id:
83
+ trace_id:,
84
+ span_stack: []
66
85
  }
67
86
  if mock_tree
68
87
  ctx[:mock_tree] = mock_tree
@@ -73,8 +92,9 @@ module Bitfab
73
92
  # is present. process_single_item fetches the tree whenever overrides
74
93
  # exist, even under mock: "none".
75
94
  ctx[:mock_overrides] = mock_overrides if mock_overrides && !mock_overrides.empty?
76
- # Lazy per-span output fetcher for the payload-free ("marked"/override)
77
- # path. Absent on the eager "all" path, whose outputs are inline.
95
+ # Lazy per-span output fetcher for a payload-free non-"all" tree path.
96
+ # Absent on the eager "all" path, whose outputs are inline even when
97
+ # overrides are present.
78
98
  ctx[:fetch_span_output] = fetch_span_output if fetch_span_output
79
99
  end
80
100
  # The per-trace DB branch (resolved server-side) and the Bitfab trace ID
@@ -93,11 +113,15 @@ module Bitfab
93
113
  # accessed for free, and the flag would stop separating "branch was
94
114
  # used" from "branch was offered".
95
115
  ctx[:db_branch_lease] = db_branch_lease if db_branch_lease
116
+ # Kept off ReplayBranch: customer code reads that mid-replay to reach the
117
+ # branch, and provisioning latency is a property of the run, not of the
118
+ # connection. It rides the context only to be echoed on the completion.
119
+ ctx[:db_branch_timings] = db_branch_timings if db_branch_timings
96
120
  ctx[:source_bitfab_trace_id] = source_bitfab_trace_id if source_bitfab_trace_id
97
- Thread.current[REPLAY_CONTEXT_KEY] = ctx
121
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, ctx)
98
122
  yield
99
123
  ensure
100
- Thread.current[REPLAY_CONTEXT_KEY] = previous
124
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, previous)
101
125
  end
102
126
  end
103
127
 
@@ -163,8 +187,9 @@ module Bitfab
163
187
  # @param method_name [Symbol] the method to replay
164
188
  # @param trace_function_key [String] the trace function key for this method
165
189
  # @param limit [Integer, nil] maximum number of traces to replay (default: 5).
166
- # Ignored when trace_ids is passed (with a warning): an explicit ID list
167
- # already determines how many traces replay.
190
+ # Ignored when trace_ids or dataset_id is passed because either source
191
+ # already determines how many traces replay. Supplying trace_ids also
192
+ # emits a warning.
168
193
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
169
194
  # @param name [String, nil] optional display name for the resulting experiment/test run
170
195
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
@@ -187,7 +212,7 @@ module Bitfab
187
212
  # same org and trace function or the server rejects the replay
188
213
  # @param mock [String] mock strategy for child spans: "marked" (default),
189
214
  # "none", or "all". "marked" only mocks spans declared with
190
- # mock_on_replay: true; "all" mocks every child span.
215
+ # mock_on_replay: true; "all" mocks every matched recorded child span.
191
216
  # @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
192
217
  # onto the method's current signature when its shape changed after the
193
218
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
@@ -206,11 +231,11 @@ module Bitfab
206
231
  # errored:, item: } where item is { trace_id:, original_trace_id:,
207
232
  # original_span_id:, error:, duration_ms: } for the single item that just
208
233
  # finished (source_trace_id/source_span_id remain as deprecated aliases).
209
- # trace_id is the new server replay trace id, written in after the run
210
- # completes (nil during progress callbacks); original_trace_id is the
211
- # ORIGINAL (historical) trace that was replayed, error is that item's replay
212
- # error or nil, and
213
- # duration_ms is how long that one trace took to replay. Use it to
234
+ # trace_id is the new server replay trace id, available after that item's
235
+ # trace is flushed (nil only when delivery could not be confirmed);
236
+ # original_trace_id is the ORIGINAL (historical) trace that was replayed,
237
+ # error is that item's replay error or nil, and duration_ms is the source
238
+ # trace's recorded duration. Use it to
214
239
  # render replay progress (e.g. a per-trace log). A raising callback never
215
240
  # crashes the run.
216
241
  # @param on_progress [#call, nil] deprecated compatibility callback. It
@@ -744,9 +769,13 @@ module Bitfab
744
769
  trace_error: result[:trace_error],
745
770
  replay_error: result[:replay_error],
746
771
  duration_ms: result[:duration_ms],
772
+ original_duration_ms: result[:original_duration_ms],
773
+ original_tokens: result[:original_tokens],
774
+ original_model: result[:original_model],
747
775
  tokens: result[:tokens],
748
776
  model: result[:model],
749
- db_snapshot_ref: result[:db_snapshot_ref]
777
+ db_snapshot_ref: result[:db_snapshot_ref],
778
+ db_branch_timings: result[:db_branch_timings]
750
779
  }
751
780
  })
752
781
  rescue => e
@@ -807,14 +836,17 @@ module Bitfab
807
836
  # (only when include_db_branch_lease was sent). Release it in the +ensure+
808
837
  # below so any raise (span fetch, mock-tree build, or the replayed
809
838
  # method) frees the Neon resource. Items whose source trace had no
810
- # snapshot ref arrive without a lease (env.active? is false), and
811
- # replaying those against the live database is correct.
839
+ # snapshot ref arrive without a lease, so the app uses its normal DB
840
+ # path. Unsafe calls on that path still require replay mocking.
812
841
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
813
842
  # A resolve that was ATTEMPTED and failed is different: the caller asked
814
843
  # for a branch, so running their method against live data would produce a
815
844
  # result that looks valid and is not. Fail the item instead, loudly.
816
845
  lease_error = include_db_branch_lease ? server_item["dbBranchLeaseError"] : nil
817
846
  db_snapshot_ref = server_item["dbSnapshotRef"]
847
+ # Reported whichever way the resolve went, so it is tracked separately
848
+ # from both the lease and the error rather than hanging off either.
849
+ db_branch_timings = include_db_branch_lease ? server_item["dbBranchTimings"] : nil
818
850
  if include_db_branch_lease && lease.nil? && lease_error.nil?
819
851
  begin
820
852
  resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings)
@@ -829,6 +861,7 @@ module Bitfab
829
861
  lease = resolved["lease"]
830
862
  lease_error = resolved["leaseError"]
831
863
  db_snapshot_ref = resolved["dbSnapshotRef"] || db_snapshot_ref
864
+ db_branch_timings = resolved["timings"] || db_branch_timings
832
865
  end
833
866
  if lease_error
834
867
  raise DbBranchReplayError.new(
@@ -847,10 +880,11 @@ module Bitfab
847
880
  # the tree must be fetched for them to fire even under mock: "none".
848
881
  #
849
882
  # Only mock: "all" needs every span's recorded output inline (it mocks
850
- # every child), so it fetches an eager tree. "marked" and overrides mock
851
- # only a few spans, so they fetch a payload-free tree (includeOutputs=
852
- # false) and pull each mocked span's output lazily by externalSpanId,
853
- # never dragging down outputs that no span consumes.
883
+ # every matched recorded child), so it fetches an eager tree even when
884
+ # overrides exist.
885
+ # Non-"all" runs that need a tree ("marked", or "none" with overrides)
886
+ # fetch it payload-free (includeOutputs=false) and pull each mocked span's
887
+ # output lazily by externalSpanId, never dragging down unused outputs.
854
888
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
855
889
  include_outputs = mock_strategy == "all"
856
890
  mock_tree = nil
@@ -864,14 +898,10 @@ module Bitfab
864
898
  mock_tree = build_mock_tree(tree["root"] || {})
865
899
  rescue Exception => e # rubocop:disable Lint/RescueException
866
900
  raise if e.is_a?(SystemExit) || e.is_a?(SignalException)
867
- # "all" and overrides both depend on the tree: "all" mocks every span
868
- # from it, and overrides gate on its call-counter machinery. If the
869
- # fetch fails, surface the error on the item rather than silently
870
- # running everything real with the overrides dropped. Only bare
871
- # "marked" can fall back to real execution (its marked spans just
872
- # re-run). Mirrors the Python SDK's has_overrides re-raise.
873
- raise if mock_strategy == "all" || overrides_present
874
- mock_tree = nil
901
+ raise unless mock_strategy == "marked" && !overrides_present
902
+ # Keep an active empty tree so the root can still run, while any
903
+ # marked child fails closed at its call site.
904
+ mock_tree = {}
875
905
  end
876
906
  end
877
907
 
@@ -905,6 +935,7 @@ module Bitfab
905
935
  adapt_inputs:,
906
936
  adapt_ctx:,
907
937
  db_branch_lease: lease,
938
+ db_branch_timings:,
908
939
  source_bitfab_trace_id: original_trace_id,
909
940
  db_snapshot_ref:,
910
941
  http_client:
@@ -918,16 +949,20 @@ module Bitfab
918
949
  error: replay_item_error_message(e),
919
950
  trace_error: nil,
920
951
  replay_error: e,
921
- duration_ms: metrics&.dig(:duration_ms),
952
+ duration_ms: nil,
953
+ original_duration_ms: metrics&.dig(:original_duration_ms),
954
+ original_tokens: metrics&.dig(:original_tokens),
955
+ original_model: metrics&.dig(:original_model),
922
956
  tokens: metrics&.dig(:tokens),
923
- model: metrics&.dig(:model),
957
+ model: metrics&.dig(:original_model),
924
958
  trace_id: nil,
925
959
  original_trace_id:,
926
960
  original_span_id:,
927
961
  # Deprecated aliases for original_trace_id/original_span_id.
928
962
  source_trace_id: original_trace_id,
929
963
  source_span_id: original_span_id,
930
- db_snapshot_ref:
964
+ db_snapshot_ref:,
965
+ db_branch_timings:
931
966
  }
932
967
  ensure
933
968
  release_db_branch_lease(http_client, lease) if lease
@@ -1041,11 +1076,15 @@ module Bitfab
1041
1076
  # the ORIGINAL trace's tokens); the replayed run's tokens are filled in by
1042
1077
  # run() from the complete-replay response once spans are aggregated
1043
1078
  # server-side, and stay nil here and on older servers.
1079
+ # The original trace's measurements, falling back to the unprefixed keys a
1080
+ # server that predates the rename sends. :tokens stays nil here: it is the
1081
+ # REPLAYED run's, filled in from the complete-replay response.
1044
1082
  def extract_server_item_metrics(server_item)
1045
1083
  {
1046
- duration_ms: server_item["durationMs"],
1047
- tokens: nil,
1048
- model: server_item["model"]
1084
+ original_duration_ms: server_item["originalDurationMs"] || server_item["durationMs"],
1085
+ original_tokens: server_item["originalTokens"] || server_item["tokens"],
1086
+ original_model: server_item["originalModel"] || server_item["model"],
1087
+ tokens: nil
1049
1088
  }
1050
1089
  end
1051
1090
 
@@ -1126,13 +1165,15 @@ module Bitfab
1126
1165
  # Execute a single replay item: deserialize inputs, call method with replay context.
1127
1166
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
1128
1167
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1129
- fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil,
1130
- db_snapshot_ref: nil, http_client: nil)
1168
+ fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, db_branch_timings: nil,
1169
+ source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil)
1131
1170
  args, kwargs = Serialize.deserialize_inputs(item)
1132
1171
 
1133
1172
  fn_result = nil
1134
1173
  fn_error = nil
1135
1174
  replay_error = nil
1175
+ replay_duration_ms = nil
1176
+ replay_started = nil
1136
1177
  # Client-side correlation id that tags this item's replay spans so the
1137
1178
  # server can echo back the row id it minted (resolved in run()'s
1138
1179
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
@@ -1154,6 +1195,7 @@ module Bitfab
1154
1195
  mock_overrides:,
1155
1196
  fetch_span_output:,
1156
1197
  db_branch_lease:,
1198
+ db_branch_timings:,
1157
1199
  source_bitfab_trace_id:
1158
1200
  ) do
1159
1201
  begin
@@ -1172,17 +1214,26 @@ module Bitfab
1172
1214
  end
1173
1215
  if replay_error.nil?
1174
1216
  begin
1217
+ replay_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1175
1218
  fn_result = if kwargs.empty?
1176
1219
  receiver.send(method_name, *args)
1177
1220
  else
1178
1221
  receiver.send(method_name, *args, **kwargs)
1179
1222
  end
1223
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1180
1224
  rescue => e
1225
+ # The method ran and raised, so it still has a duration.
1226
+ if replay_started
1227
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1228
+ end
1181
1229
  fn_error = e
1182
1230
  end
1183
1231
  end
1184
1232
  end
1185
1233
  rescue => e
1234
+ if replay_started
1235
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1236
+ end
1186
1237
  replay_error = e
1187
1238
  end
1188
1239
 
@@ -1194,9 +1245,12 @@ module Bitfab
1194
1245
  error: item_error&.message,
1195
1246
  trace_error: fn_error,
1196
1247
  replay_error:,
1197
- duration_ms: metrics[:duration_ms],
1248
+ duration_ms: replay_duration_ms,
1249
+ original_duration_ms: metrics[:original_duration_ms],
1250
+ original_tokens: metrics[:original_tokens],
1251
+ original_model: metrics[:original_model],
1198
1252
  tokens: metrics[:tokens],
1199
- model: metrics[:model],
1253
+ model: metrics[:original_model],
1200
1254
  # Written in by run() from the complete-replay response once the server
1201
1255
  # has minted this replay trace's row. Nil until then: the client-side
1202
1256
  # correlation id (below) is never surfaced as the public :trace_id.
@@ -1207,7 +1261,8 @@ module Bitfab
1207
1261
  # Deprecated aliases for original_trace_id/original_span_id.
1208
1262
  source_trace_id: source_bitfab_trace_id,
1209
1263
  source_span_id: input_source_span_id,
1210
- db_snapshot_ref:
1264
+ db_snapshot_ref:,
1265
+ db_branch_timings:
1211
1266
  }
1212
1267
  end
1213
1268
  end
@@ -114,6 +114,7 @@ module Bitfab
114
114
  # Each entry is a Hash with :trace_id and :span_id keys.
115
115
  module SpanContext
116
116
  STACK_KEY = :__bitfab_span_stack
117
+ FIBER_BRIDGE_STACK_KEY = :__bitfab_span_fiber_bridge_stack
117
118
 
118
119
  module_function
119
120
 
@@ -122,7 +123,18 @@ module Bitfab
122
123
  end
123
124
 
124
125
  def current
125
- stack.last
126
+ stack.last || fiber_bridge_stack.last
127
+ end
128
+
129
+ # Enumerator.new and enum_for run their source body in another Fiber on
130
+ # the same thread. Bridge only the active Enumerator parent across that
131
+ # boundary; ordinary fiber-local span stacks remain isolated.
132
+ def with_fiber_bridge(trace_id:, span_id:)
133
+ entry = {trace_id:, span_id:}
134
+ fiber_bridge_stack.push(entry)
135
+ yield
136
+ ensure
137
+ fiber_bridge_stack.pop
126
138
  end
127
139
 
128
140
  # Execute a block with a new span pushed onto the stack.
@@ -134,6 +146,15 @@ module Bitfab
134
146
  ensure
135
147
  stack.pop
136
148
  end
149
+
150
+ def fiber_bridge_stack
151
+ current_thread = Thread.current
152
+ current_thread.thread_variable_get(FIBER_BRIDGE_STACK_KEY) || begin
153
+ value = []
154
+ current_thread.thread_variable_set(FIBER_BRIDGE_STACK_KEY, value)
155
+ value
156
+ end
157
+ end
137
158
  end
138
159
 
139
160
  # Global storage for trace states (trace_id -> state hash)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.10"
4
+ VERSION = "0.38.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.36.10
4
+ version: 0.38.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team