bitfab 0.36.9 → 0.36.11

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: 23d0d3e96160502ecf10da7fe952d08fec34439595e4c61e50ce9ea7c086a11c
4
- data.tar.gz: 7bb58eab2f4749bd073593ec4c359d8460d1a1012eab60727999ad765a3e6d1e
3
+ metadata.gz: 0d23fea40d370a085bd028fbf7b605a6b66bc0732a1580683b3f027b5b2082e9
4
+ data.tar.gz: a477ee5856fc6fb25a39a6088f820337cd2bcb327c3b028c9051d0b0f84edd5d
5
5
  SHA512:
6
- metadata.gz: 7360217eac4dfaa7ae66c645415598a9b794e2433434b425de54a1922243a93012270d9146309aca8d3ee65e9084da047cd6c6a5751cade83e1eb7b7d7458ab7
7
- data.tar.gz: ee2575e01da6352effba7a1943621aa44280aabd5ead4aed76ce85561db6ccd8251c28c369de846fce1879e8ff4a08da9a6211b924d40f0a78430bec98302e76
6
+ metadata.gz: bb155e9ec03187b1f9f80a918b8df651bab41e181afcdd5c068c9645ab6b9c94884d7f8c41cab43bd50e878226e3d075962c02ac27e2b33ed2729e979b4e4464
7
+ data.tar.gz: 1da28622e46e44b40f5296ed699ee13e897e7bd0a54177e32f3c629fb8004ea5f87a86546a37d608541ff771ba648fffb128bd613d7aefea300c8898038c7e47
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)
@@ -325,11 +329,12 @@ module Bitfab
325
329
  # counters: they cannot shift each other.
326
330
  call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
327
331
  if call_index
328
- mocked_output = check_mock_replay(
332
+ mock_result = check_mock_replay(
329
333
  replay_ctx, trace_function_key, span_name, call_index,
330
334
  span_type:, args:, kwargs:, mock_on_replay:
331
335
  )
332
- if mocked_output != MOCK_REPLAY_MISS
336
+ if mock_result != MOCK_REPLAY_MISS
337
+ mocked_output, mock_source = mock_result
333
338
  send_mocked_span(
334
339
  trace_function_key:,
335
340
  trace_id:,
@@ -343,7 +348,8 @@ module Bitfab
343
348
  mocked_output:,
344
349
  started_at:,
345
350
  test_run_id: resolved_test_run_id,
346
- input_source_span_id: resolved_input_source_span_id
351
+ input_source_span_id: resolved_input_source_span_id,
352
+ mock_source:
347
353
  )
348
354
  return mocked_output
349
355
  end
@@ -438,12 +444,14 @@ module Bitfab
438
444
  end
439
445
 
440
446
  begin
441
- SpanContext.with_span(trace_id:, span_id:) do
442
- result = yield
443
- ensure
444
- # Capture contexts before the span context is popped
445
- span_contexts = SpanContext.current&.dig(:contexts)
446
- span_prompt = SpanContext.current&.dig(:prompt)
447
+ ReplayContext.with_span(trace_id:, span_id:) do
448
+ SpanContext.with_span(trace_id:, span_id:) do
449
+ result = yield
450
+ ensure
451
+ # Capture contexts before the span context is popped
452
+ span_contexts = SpanContext.current&.dig(:contexts)
453
+ span_prompt = SpanContext.current&.dig(:prompt)
454
+ end
447
455
  end
448
456
  rescue => e
449
457
  error = e.message
@@ -464,12 +472,6 @@ module Bitfab
464
472
  # collects yielded values as the span output, and finalizes the span
465
473
  # once iteration completes (or errors).
466
474
  #
467
- # Limitation: when the source enumerator itself runs its body in a
468
- # separate fiber (e.g. `Enumerator.new { |y| ... }` or `enum_for(...)`
469
- # without a block), nested `bitfab_span` calls inside that body fiber
470
- # still see an empty stack because `Thread.current[STACK_KEY]` is
471
- # fiber-local. Lazy chains over collections (`.lazy.map`) and ordinary
472
- # `each` callbacks DO run in the iterating fiber and nest correctly.
473
475
  if result.is_a?(Enumerator)
474
476
  return wrap_enumerator(result, trace_id:, span_id:, finalize:)
475
477
  end
@@ -542,19 +544,24 @@ module Bitfab
542
544
  Enumerator.new do |yielder|
543
545
  # Push our span onto this fiber's stack so anything that runs in this
544
546
  # fiber (including the user's lazy block and `each` callbacks) sees
545
- # the right parent.
546
- SpanContext.stack.push(span_entry)
547
- begin
548
- source.each do |value|
549
- yielded << value
550
- yielder << value
547
+ # the right parent. The fiber bridge carries the same parent into
548
+ # Enumerator.new / enum_for source fibers on this thread.
549
+ ReplayContext.with_span(trace_id:, span_id:) do
550
+ SpanContext.with_fiber_bridge(trace_id:, span_id:) do
551
+ SpanContext.stack.push(span_entry)
552
+ begin
553
+ source.each do |value|
554
+ yielded << value
555
+ yielder << value
556
+ end
557
+ finalize.call(yielded, nil)
558
+ rescue => e
559
+ finalize.call(yielded, e.message)
560
+ raise
561
+ ensure
562
+ SpanContext.stack.pop
563
+ end
551
564
  end
552
- finalize.call(yielded, nil)
553
- rescue => e
554
- finalize.call(yielded, e.message)
555
- raise
556
- ensure
557
- SpanContext.stack.pop
558
565
  end
559
566
  end
560
567
  end
@@ -641,7 +648,8 @@ module Bitfab
641
648
 
642
649
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
643
650
  span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
644
- started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil, mocked: false)
651
+ started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil, mocked: false,
652
+ mock_target: nil, mock_source: nil)
645
653
  # If drop() was called on this trace, suppress the span PAYLOAD upload for
646
654
  # every span that completes after the flag was set. The trace completion
647
655
  # still rides out with dropped: true, so the server scrubs any sibling
@@ -701,6 +709,10 @@ module Bitfab
701
709
  # replay so the trace view can mark them (matches the Python and
702
710
  # TypeScript SDKs).
703
711
  payload["mocked"] = true if mocked
712
+ # How the mock was produced, when known: what it replaced, and whether the
713
+ # value was the original trace's own or one the caller supplied.
714
+ payload["mockTarget"] = mock_target if mock_target
715
+ payload["mockSource"] = mock_source if mock_source
704
716
 
705
717
  # A value that could only be captured as a placeholder makes the span
706
718
  # non-replayable; record it on the payload's errors (matching the Python
@@ -769,7 +781,7 @@ module Bitfab
769
781
  # override always injects, execute_span short-circuits even when this
770
782
  # is nil (nil != MOCK_REPLAY_MISS). To inject a proc as the literal
771
783
  # output, wrap it in a callable value.
772
- return value unless value.respond_to?(:call)
784
+ return [value, "override"] unless value.respond_to?(:call)
773
785
 
774
786
  get_original_output = lambda do
775
787
  unless mock_entry
@@ -777,7 +789,7 @@ module Bitfab
777
789
  end
778
790
  resolve_recorded_output(mock_entry, replay_ctx)
779
791
  end
780
- return value.call({node:, inputs: args, kwargs:, get_original_output:})
792
+ return [value.call({node:, inputs: args, kwargs:, get_original_output:}), "override"]
781
793
  end
782
794
  end
783
795
 
@@ -792,9 +804,13 @@ module Bitfab
792
804
  return MOCK_REPLAY_MISS
793
805
  end
794
806
 
795
- return MOCK_REPLAY_MISS unless mock_entry
807
+ unless mock_entry
808
+ raise StandardError,
809
+ "Replay selected span '#{trace_function_key}:#{span_name}' for mocking, " \
810
+ "but recorded occurrence #{call_index + 1} is unavailable. The real span was not executed."
811
+ end
796
812
 
797
- resolve_recorded_output(mock_entry, replay_ctx)
813
+ [resolve_recorded_output(mock_entry, replay_ctx), "recorded"]
798
814
  end
799
815
 
800
816
  # Resolve a mock-tree entry's recorded output. Prefers an inline output when
@@ -833,7 +849,7 @@ module Bitfab
833
849
  # output as the result and no error.
834
850
  def send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
835
851
  span_name:, span_type:, function_name:, args:, kwargs:, mocked_output:,
836
- started_at:, test_run_id:, input_source_span_id:)
852
+ started_at:, test_run_id:, input_source_span_id:, mock_source:)
837
853
  ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
838
854
  send_span(
839
855
  trace_function_key:,
@@ -853,7 +869,11 @@ module Bitfab
853
869
  ended_at:,
854
870
  test_run_id:,
855
871
  input_source_span_id:,
856
- mocked: true
872
+ mocked: true,
873
+ # Both paths short-circuit the call, so the target is always the
874
+ # output; only where the value came from differs.
875
+ mock_target: "output",
876
+ mock_source:
857
877
  )
858
878
  rescue Exception # rubocop:disable Lint/RescueException
859
879
  # Never crash the host app: mocked span recording is best-effort
@@ -891,7 +911,8 @@ module Bitfab
891
911
  # @param capture_when [String, Symbol] "always" to allow a root trace, or
892
912
  # "nested" to capture only when another Bitfab span is active. Unknown
893
913
  # values warn once and default to "always"
894
- # @param mock_on_replay [Boolean] mark this span for the default "marked" mock strategy
914
+ # @param mock_on_replay [Boolean] mark this span for the default "marked"
915
+ # mock strategy. A missing selected occurrence fails closed.
895
916
  def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false)
896
917
  Bitfab::Traceable.wrap(
897
918
  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
77
  db_branch_lease: nil, source_bitfab_trace_id: nil)
60
- previous = Thread.current[REPLAY_CONTEXT_KEY]
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
@@ -94,10 +114,10 @@ module Bitfab
94
114
  # used" from "branch was offered".
95
115
  ctx[:db_branch_lease] = db_branch_lease if db_branch_lease
96
116
  ctx[:source_bitfab_trace_id] = source_bitfab_trace_id if source_bitfab_trace_id
97
- Thread.current[REPLAY_CONTEXT_KEY] = ctx
117
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, ctx)
98
118
  yield
99
119
  ensure
100
- Thread.current[REPLAY_CONTEXT_KEY] = previous
120
+ Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, previous)
101
121
  end
102
122
  end
103
123
 
@@ -163,8 +183,9 @@ module Bitfab
163
183
  # @param method_name [Symbol] the method to replay
164
184
  # @param trace_function_key [String] the trace function key for this method
165
185
  # @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.
186
+ # Ignored when trace_ids or dataset_id is passed because either source
187
+ # already determines how many traces replay. Supplying trace_ids also
188
+ # emits a warning.
168
189
  # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
169
190
  # @param name [String, nil] optional display name for the resulting experiment/test run
170
191
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
@@ -187,7 +208,7 @@ module Bitfab
187
208
  # same org and trace function or the server rejects the replay
188
209
  # @param mock [String] mock strategy for child spans: "marked" (default),
189
210
  # "none", or "all". "marked" only mocks spans declared with
190
- # mock_on_replay: true; "all" mocks every child span.
211
+ # mock_on_replay: true; "all" mocks every matched recorded child span.
191
212
  # @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
192
213
  # onto the method's current signature when its shape changed after the
193
214
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
@@ -206,11 +227,11 @@ module Bitfab
206
227
  # errored:, item: } where item is { trace_id:, original_trace_id:,
207
228
  # original_span_id:, error:, duration_ms: } for the single item that just
208
229
  # 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
230
+ # trace_id is the new server replay trace id, available after that item's
231
+ # trace is flushed (nil only when delivery could not be confirmed);
232
+ # original_trace_id is the ORIGINAL (historical) trace that was replayed,
233
+ # error is that item's replay error or nil, and duration_ms is the source
234
+ # trace's recorded duration. Use it to
214
235
  # render replay progress (e.g. a per-trace log). A raising callback never
215
236
  # crashes the run.
216
237
  # @param on_progress [#call, nil] deprecated compatibility callback. It
@@ -807,8 +828,8 @@ module Bitfab
807
828
  # (only when include_db_branch_lease was sent). Release it in the +ensure+
808
829
  # below so any raise (span fetch, mock-tree build, or the replayed
809
830
  # 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.
831
+ # snapshot ref arrive without a lease, so the app uses its normal DB
832
+ # path. Unsafe calls on that path still require replay mocking.
812
833
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
813
834
  # A resolve that was ATTEMPTED and failed is different: the caller asked
814
835
  # for a branch, so running their method against live data would produce a
@@ -847,10 +868,11 @@ module Bitfab
847
868
  # the tree must be fetched for them to fire even under mock: "none".
848
869
  #
849
870
  # 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.
871
+ # every matched recorded child), so it fetches an eager tree even when
872
+ # overrides exist.
873
+ # Non-"all" runs that need a tree ("marked", or "none" with overrides)
874
+ # fetch it payload-free (includeOutputs=false) and pull each mocked span's
875
+ # output lazily by externalSpanId, never dragging down unused outputs.
854
876
  overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
855
877
  include_outputs = mock_strategy == "all"
856
878
  mock_tree = nil
@@ -864,14 +886,10 @@ module Bitfab
864
886
  mock_tree = build_mock_tree(tree["root"] || {})
865
887
  rescue Exception => e # rubocop:disable Lint/RescueException
866
888
  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
889
+ raise unless mock_strategy == "marked" && !overrides_present
890
+ # Keep an active empty tree so the root can still run, while any
891
+ # marked child fails closed at its call site.
892
+ mock_tree = {}
875
893
  end
876
894
  end
877
895
 
@@ -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.9"
4
+ VERSION = "0.36.11"
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.9
4
+ version: 0.36.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team