bitfab 0.23.8 → 0.29.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: 342b5de3b40edd60f060465123f21d1c5338063340b34ac7bb7067c7baedfe55
4
- data.tar.gz: 2f73bec442f3570efe78253d28733942e00b46c03a26ce2731a72e778cd6d79f
3
+ metadata.gz: 61a89926a13fa008e693f825bb810dea734a1b17bed7ab9b9821daec3e36830f
4
+ data.tar.gz: 5026a5ee5252b0a9cf7a8653287e79ac626fa144bd160e7ecf138131dd8c5dba
5
5
  SHA512:
6
- metadata.gz: c76023257bcdd959cb49d6881faf05e85013b2d30c02826fd57a2a2aa8a72d72d7f8f10716f9f4b2d850a84dfb4a6ef7151077b88f43e2e1e7bd5b4c81ed23ff
7
- data.tar.gz: 37fb2e1215527c780253dd35e10e4ff01d735336d068d5d8ac16a75d4fcc17e1749f7718cf8ffc550e965a27a9f0ee8fb49b2b847724c10a1ae031f0f1470574
6
+ metadata.gz: 803c6b775c40a3c7dad53d4b380c2b844d94c908b56e58ea899e359e674b86f553d4dd14f9c6aa1e7ad6a6552991fec8c24688a482df57f3cdedda4345a0483b
7
+ data.tar.gz: 4086ecadb3d8a1359632d8fee0df1b6bd6febac396b7ac0f5374259fba3c3e0b4ec347c47c3ecc2094da0c02753a780de632a08c7b946389f27a96a8af1467d5
data/lib/bitfab/client.rb CHANGED
@@ -21,6 +21,12 @@ module Bitfab
21
21
  # outputs (which may themselves be nil or false).
22
22
  MOCK_REPLAY_MISS = Object.new.freeze
23
23
 
24
+ # Sentinel for an OMITTED override value in register_mock_override, so a
25
+ # forgotten `value:` raises instead of silently injecting nil. An explicit
26
+ # `value: nil` is a legitimate injected value and is distinct from this.
27
+ VALUE_UNSET = Object.new.freeze
28
+ private_constant :VALUE_UNSET
29
+
24
30
  attr_reader :service_url
25
31
 
26
32
  def initialize(api_key: nil, service_url: nil, enabled: true, strict: false)
@@ -38,6 +44,10 @@ module Bitfab
38
44
  @http_client = HttpClient.new(api_key: -> { resolve_api_key }, service_url: @service_url)
39
45
  @pending_span_threads = {}
40
46
  @pending_span_mutex = Mutex.new
47
+ # Mock overrides registered via register_mock_override, applied to every
48
+ # replay on this client (after any per-call mock_override). Instance
49
+ # state, no global; clear_mock_overrides resets it.
50
+ @mock_overrides = []
41
51
  end
42
52
 
43
53
  # The configured API key (a proc is resolved on read). Reflects what was
@@ -80,6 +90,16 @@ module Bitfab
80
90
  # onto the method's current signature when its shape changed after the
81
91
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
82
92
  # { trace_id:, source_span_id: }, and returns [new_args, new_kwargs].
93
+ # @param mock_override [Hash, Array<Hash>, nil] optional selective mock
94
+ # override(s), each a { match:, value: } hash. match is a callable:
95
+ # match.call(node) selects spans to substitute (node is
96
+ # { trace_function_key:, span_name:, type:, original_span_id: }). value is
97
+ # EITHER a flat value injected directly OR a callable invoked with
98
+ # { node:, inputs:, get_original_output: }; its result becomes the span's
99
+ # output (full replacement), so downstream real code runs against it. The
100
+ # first matching override wins. Per-call overrides take precedence over
101
+ # those registered via register_mock_override, and both take precedence
102
+ # over the base mock strategy. The root span is never overridden.
83
103
  # @param on_progress [#call, nil] optional callback invoked once per item as
84
104
  # it finishes, with a running-totals hash { completed:, total:, succeeded:,
85
105
  # errored: }. Use it to render replay progress (e.g. a terminal progress
@@ -89,15 +109,84 @@ module Bitfab
89
109
  # @return [Hash] with :items, :test_run_id, :test_run_url
90
110
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
91
111
  name: nil, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "marked",
92
- adapt_inputs: nil, environment: nil, on_progress: nil)
112
+ adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
93
113
  Replay.run(
94
114
  self, receiver, method_name,
95
115
  trace_function_key:, limit:, trace_ids:, name:, max_concurrency:,
96
- code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:, environment:,
116
+ code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:,
117
+ mock_override:, environment:,
97
118
  on_progress:
98
119
  )
99
120
  end
100
121
 
122
+ # Register a mock override applied to every subsequent replay on this
123
+ # client, so downstream real code runs against a value you supply for the
124
+ # matched span. Instance-scoped (no global state); call
125
+ # clear_mock_overrides to reset. Per-call replay(mock_override:) overrides
126
+ # take precedence, and both take precedence over the base mock strategy.
127
+ #
128
+ # Accepts either the (match, value) positional form or the keyword form
129
+ # (a { match:, value: } hash is also accepted). `match` must be callable;
130
+ # `value` is either a flat value injected directly or a callable invoked
131
+ # with the ctx hash.
132
+ #
133
+ # @example keyword form (primary), flat value
134
+ # client.register_mock_override(
135
+ # match: ->(node) { node[:trace_function_key] == "classify-intent" },
136
+ # value: {label: "refund"}
137
+ # )
138
+ #
139
+ # @example positional form (equivalent), callable value
140
+ # client.register_mock_override(
141
+ # ->(node) { node[:trace_function_key] == "classify-intent" },
142
+ # ->(ctx) { {label: "refund", inputs: ctx[:inputs]} }
143
+ # )
144
+ #
145
+ # @param positional [Array] either (match, value) or a single
146
+ # { match:, value: } hash
147
+ # @param match [#call, nil] keyword form matcher
148
+ # @param value [Object, #call, nil] keyword form injected value or producer
149
+ # @return [void]
150
+ def register_mock_override(*positional, match: nil, value: VALUE_UNSET)
151
+ if match.nil? && value.equal?(VALUE_UNSET)
152
+ if positional.length == 1 && positional[0].is_a?(Hash)
153
+ override = positional[0]
154
+ match = override[:match]
155
+ # Distinguish an omitted :value from an explicit `value: nil`: the
156
+ # key's presence, not a nil read, is what marks it provided.
157
+ value = override.key?(:value) ? override[:value] : VALUE_UNSET
158
+ elsif positional.length == 2
159
+ match, value = positional
160
+ end
161
+ end
162
+
163
+ unless match.respond_to?(:call)
164
+ raise ArgumentError,
165
+ "register_mock_override requires a callable match. Pass (match, value) " \
166
+ "positionally, as keywords (match:, value:), or as a { match:, value: } hash. " \
167
+ "value may be a flat value or a callable."
168
+ end
169
+ # A forgotten value must not silently inject nil. An explicit nil is a
170
+ # legitimate injected value and passes this guard.
171
+ if value.equal?(VALUE_UNSET)
172
+ raise ArgumentError,
173
+ "register_mock_override requires a value (the second argument, or " \
174
+ "value:). It may be a flat value or a callable; pass value: nil " \
175
+ "explicitly to inject nil."
176
+ end
177
+
178
+ @mock_overrides << {match:, value:}
179
+ nil
180
+ end
181
+
182
+ # Remove all overrides registered via register_mock_override.
183
+ #
184
+ # @return [void]
185
+ def clear_mock_overrides
186
+ @mock_overrides.clear
187
+ nil
188
+ end
189
+
101
190
  # Get a function wrapper bound to a specific trace function key.
102
191
  #
103
192
  # This provides a fluent API for binding a trace_function_key once and
@@ -192,7 +281,8 @@ module Bitfab
192
281
  call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
193
282
  if call_index
194
283
  mocked_output = check_mock_replay(
195
- replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
284
+ replay_ctx, trace_function_key, span_name, call_index,
285
+ span_type:, args:, kwargs:, mock_on_replay:
196
286
  )
197
287
  if mocked_output != MOCK_REPLAY_MISS
198
288
  send_mocked_span(
@@ -617,10 +707,53 @@ module Bitfab
617
707
  call_index
618
708
  end
619
709
 
620
- # Decide whether this child span should be short-circuited to its recorded
621
- # output. Returns MOCK_REPLAY_MISS when the span should run real code,
622
- # otherwise returns the deserialized historical output.
623
- def check_mock_replay(replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:)
710
+ # Decide whether this child span should be short-circuited during replay.
711
+ # Returns MOCK_REPLAY_MISS when the span should run real code, otherwise
712
+ # returns the value to inject (a selective override's produced value, or the
713
+ # deserialized historical output under the base "all"/"marked" strategy).
714
+ #
715
+ # Precedence: a matching mock override wins over the base strategy (per-call
716
+ # overrides already precede registered ones in the resolved list). A span no
717
+ # override matches falls through to the marked/all logic unchanged. An
718
+ # override that matches ALWAYS injects, even when it returns nil, so a
719
+ # legitimately-nil injected value is never mistaken for a miss.
720
+ def check_mock_replay(replay_ctx, trace_function_key, span_name, call_index, span_type:, args:, kwargs:, mock_on_replay:)
721
+ mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
722
+
723
+ # 1) Selective overrides. The matcher runs on structural metadata only;
724
+ # the recorded output is fetched (lazily, by externalSpanId) only if the
725
+ # processor actually asks for it via get_original_output, so a pure
726
+ # synthetic override triggers zero output fetches.
727
+ overrides = replay_ctx[:mock_overrides]
728
+ if overrides&.any?
729
+ node = {
730
+ trace_function_key:,
731
+ span_name:,
732
+ type: span_type,
733
+ original_span_id: mock_entry && mock_entry[:source_span_id]
734
+ }
735
+ override = overrides.find { |o| o[:match].call(node) }
736
+ if override
737
+ value = override[:value]
738
+ # value is EITHER a flat value (injected directly) OR a callable
739
+ # invoked with the ctx hash. The result IS the span's output (full
740
+ # replacement). nil is a legitimate injected value: because a matching
741
+ # override always injects, execute_span short-circuits even when this
742
+ # is nil (nil != MOCK_REPLAY_MISS). To inject a proc as the literal
743
+ # output, wrap it in a callable value.
744
+ return value unless value.respond_to?(:call)
745
+
746
+ get_original_output = lambda do
747
+ unless mock_entry
748
+ raise "No recorded span to source output for '#{trace_function_key}'."
749
+ end
750
+ resolve_recorded_output(mock_entry, replay_ctx)
751
+ end
752
+ return value.call({node:, inputs: args, kwargs:, get_original_output:})
753
+ end
754
+ end
755
+
756
+ # 2) Base strategy: replay recorded output for eligible spans.
624
757
  strategy = replay_ctx[:mock_strategy]
625
758
  case strategy
626
759
  when "marked"
@@ -631,25 +764,40 @@ module Bitfab
631
764
  return MOCK_REPLAY_MISS
632
765
  end
633
766
 
634
- mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
635
767
  return MOCK_REPLAY_MISS unless mock_entry
636
768
 
637
- output = mock_entry[:output]
638
- output_meta = mock_entry[:output_meta]
769
+ resolve_recorded_output(mock_entry, replay_ctx)
770
+ end
639
771
 
640
- # Type-preserving deserialization when the server included Ruby-side
641
- # Marshal+Base64 metadata. Falls back to the JSON output silently: the
642
- # spanTree endpoint currently returns superjson/jsonpickle-shaped meta,
643
- # which Ruby cannot reconstruct.
644
- if output_meta.is_a?(String) && !output_meta.empty?
645
- begin
646
- output = Serialize.unmarshal_value(output_meta)
647
- rescue
648
- # Fall through to the JSON output
649
- end
772
+ # Resolve a mock-tree entry's recorded output. Prefers an inline output when
773
+ # the tree carried one (eager "all", or an older server that ignores
774
+ # includeOutputs and returns outputs); otherwise lazily fetches it by
775
+ # externalSpanId through the per-item memoized fetcher on the replay
776
+ # context, so only the spans actually mocked pull their output.
777
+ #
778
+ # Deserialization is type-preserving when Ruby-side Marshal+Base64 metadata
779
+ # is present, falling back to the raw JSON output when the meta is a shape
780
+ # Ruby cannot reconstruct (e.g. superjson/jsonpickle from another SDK).
781
+ def resolve_recorded_output(mock_entry, replay_ctx)
782
+ # Presence of the :output (or :output_meta) key means the tree carried the
783
+ # output inline - including a legitimately nil inline output. A
784
+ # payload-free entry has neither key and is fetched lazily below.
785
+ if mock_entry.key?(:output) || mock_entry.key?(:output_meta)
786
+ return Serialize.deserialize_output(mock_entry[:output], mock_entry[:output_meta])
787
+ end
788
+
789
+ fetcher = replay_ctx[:fetch_span_output]
790
+ external_span_id = mock_entry[:external_span_id]
791
+ if fetcher && external_span_id
792
+ return fetcher.call(external_span_id)
650
793
  end
651
794
 
652
- output
795
+ # No inline output and nothing to lazily fetch. This is a span that never
796
+ # recorded an output (e.g. an errored or incomplete child): inject nil
797
+ # rather than raising, so it does not fail the whole replay item. Mirrors
798
+ # the Python and TypeScript SDKs, which return the (absent) recorded output
799
+ # as null here.
800
+ nil
653
801
  end
654
802
 
655
803
  # Record a span entry for a mocked invocation so the test run reflects the
@@ -80,6 +80,7 @@ module Bitfab
80
80
  http.open_timeout = request_timeout
81
81
  http.read_timeout = request_timeout
82
82
 
83
+ # request_uri (not path) so any query string on the endpoint survives.
83
84
  req = Net::HTTP::Get.new(uri.request_uri, headers)
84
85
  response = http.request(req)
85
86
 
@@ -158,10 +159,16 @@ module Bitfab
158
159
  # matched against their historical outputs.
159
160
  #
160
161
  # Returns a hash shaped { "root" => SpanTreeNode } where each node has
161
- # sourceSpanId, traceFunctionKey, spanName, type, output, optional
162
- # outputMeta, and children.
163
- def get_span_tree(external_span_id)
164
- get("/api/sdk/replay/spanTree/#{external_span_id}", timeout: 30)
162
+ # sourceSpanId, externalSpanId, traceFunctionKey, spanName, type, and
163
+ # children. When include_outputs is true each node also carries its recorded
164
+ # output (and optional outputMeta); when false the server omits the output
165
+ # payloads so only the spans actually mocked are fetched later (by
166
+ # externalSpanId), avoiding dragging down every span's output when only a
167
+ # few are mocked.
168
+ def get_span_tree(external_span_id, include_outputs: true)
169
+ endpoint = "/api/sdk/replay/spanTree/#{external_span_id}"
170
+ endpoint += "?includeOutputs=false" unless include_outputs
171
+ get(endpoint, timeout: 30)
165
172
  end
166
173
 
167
174
  # Mark a replay test run as completed. Blocking call.
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Selective mock overrides for replay. Mirrors the TypeScript SDK's
5
+ # mockOverride feature.
6
+ #
7
+ # A mock override injects a custom value into a specific span (node) during
8
+ # replay: the matched span short-circuits its real execution and returns the
9
+ # value the processor produces, so downstream real code runs against the
10
+ # substituted output. This is a third mock mode alongside "run real code" and
11
+ # "replay recorded output" (see MOCK_STRATEGIES in replay.rb).
12
+ #
13
+ # An override is a (match, value) pair, represented as a symbol-keyed hash
14
+ # { match:, value: }:
15
+ #
16
+ # - match is a callable (proc/lambda): match.call(node) -> boolean. node is a
17
+ # structural-metadata hash { trace_function_key:, span_name:, type:,
18
+ # original_span_id: } (symbol keys; original_span_id may be nil when the
19
+ # live span has no recorded counterpart). Matching must not depend on the
20
+ # recorded output.
21
+ #
22
+ # - value is EITHER a flat value OR a callable. A flat value is injected
23
+ # directly (e.g. value: { label: "refund" }). A callable is invoked with
24
+ # the ctx hash: value.call(ctx) where ctx is
25
+ # { node:, inputs:, kwargs:, get_original_output: } - inputs is the live
26
+ # replay positional args array, kwargs is the live keyword args hash (empty
27
+ # when the call used none), and get_original_output is a proc returning this
28
+ # span's recorded output (deserialized; raises if there is no recorded
29
+ # counterpart). Either
30
+ # way the resulting value IS the span's output (full replacement, no merge);
31
+ # nil is a legitimate injected value. To inject a proc as the literal
32
+ # output, wrap it in a callable value: value: ->(ctx) { the_proc }.
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.
38
+ module MockOverride
39
+ module_function
40
+
41
+ # Normalize the per-call `mock_override` option (a single override hash, an
42
+ # array of them, or nil) into an array. First match wins downstream, so
43
+ # order is preserved.
44
+ #
45
+ # Each override is validated: it must be a hash with a callable `:match` and
46
+ # must include a `:value` key. A forgotten `:value` raises rather than
47
+ # silently short-circuiting matched spans with nil (an explicit `value: nil`
48
+ # is a legitimate injected value and passes). Mirrors the guard on
49
+ # `register_mock_override`, and the Python/TypeScript SDKs, where the per-call
50
+ # override object requires a value.
51
+ #
52
+ # @param mock_override [Hash, Array<Hash>, nil]
53
+ # @return [Array<Hash>]
54
+ def normalize(mock_override)
55
+ return [] if mock_override.nil?
56
+
57
+ overrides = mock_override.is_a?(Array) ? mock_override : [mock_override]
58
+ overrides.each do |override|
59
+ unless override.is_a?(Hash) && override[:match].respond_to?(:call)
60
+ raise ArgumentError,
61
+ "mock_override requires a callable :match. Pass a " \
62
+ "{ match:, value: } hash (or an array of them). value may be a " \
63
+ "flat value or a callable."
64
+ end
65
+ unless override.key?(:value)
66
+ raise ArgumentError,
67
+ "mock_override requires a :value (a flat value or a callable); " \
68
+ "pass value: nil explicitly to inject nil."
69
+ end
70
+ end
71
+ overrides
72
+ end
73
+ end
74
+ end
data/lib/bitfab/replay.rb CHANGED
@@ -4,6 +4,7 @@ require "fileutils"
4
4
  require "json"
5
5
 
6
6
  require_relative "constants"
7
+ require_relative "mock_override"
7
8
  require_relative "serialize"
8
9
  require_relative "traceable"
9
10
 
@@ -31,8 +32,8 @@ module Bitfab
31
32
  # threads (span uploads + trace completion) so the replay runner can join
32
33
  # them before complete_replay builds the trace-ID mapping.
33
34
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
34
- mock_tree: nil, mock_strategy: nil, pending_persistence: nil, db_branch_lease: nil,
35
- source_bitfab_trace_id: nil)
35
+ mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil, pending_persistence: nil,
36
+ db_branch_lease: nil, source_bitfab_trace_id: nil)
36
37
  previous = Thread.current[REPLAY_CONTEXT_KEY]
37
38
  ctx = {
38
39
  test_run_id:,
@@ -45,6 +46,14 @@ module Bitfab
45
46
  ctx[:mock_tree] = mock_tree
46
47
  ctx[:mock_strategy] = mock_strategy || "marked"
47
48
  ctx[:call_counters] = {}
49
+ # Selective mock overrides ride alongside the tree: they gate on the
50
+ # same non-root call-counter machinery, so they only fire when a tree
51
+ # is present. process_single_item fetches the tree whenever overrides
52
+ # exist, even under mock: "none".
53
+ ctx[:mock_overrides] = mock_overrides if mock_overrides && !mock_overrides.empty?
54
+ # Lazy per-span output fetcher for the payload-free ("marked"/override)
55
+ # path. Absent on the eager "all" path, whose outputs are inline.
56
+ ctx[:fetch_span_output] = fetch_span_output if fetch_span_output
48
57
  end
49
58
  # The per-trace DB branch (resolved server-side) and the Bitfab trace ID
50
59
  # it belongs to ride on the context so ReplayEnvironment can read them
@@ -116,7 +125,7 @@ module Bitfab
116
125
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
117
126
  max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil,
118
127
  dataset_id: nil, mock: "marked",
119
- adapt_inputs: nil, environment: nil, on_progress: nil)
128
+ adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
120
129
  unless MOCK_STRATEGIES.include?(mock.to_s)
121
130
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
122
131
  end
@@ -148,6 +157,13 @@ module Bitfab
148
157
 
149
158
  http_client = client.instance_variable_get(:@http_client)
150
159
 
160
+ # Resolved override list: per-call overrides FIRST (they win), then the
161
+ # client's registered overrides. First matching override wins downstream,
162
+ # so this ordering makes per-call take precedence over registered, and
163
+ # both take precedence over the base mock strategy.
164
+ registered_overrides = client.instance_variable_get(:@mock_overrides) || []
165
+ resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides
166
+
151
167
  # limit is meaningless with explicit trace_ids (the ID list determines
152
168
  # the count), so it's omitted from the request entirely.
153
169
  effective_limit = trace_ids ? nil : (limit || 5)
@@ -171,7 +187,7 @@ module Bitfab
171
187
 
172
188
  result_items = if server_items.any?
173
189
  process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
174
- adapt_inputs, include_db_branch_lease, on_progress:)
190
+ adapt_inputs, include_db_branch_lease, on_progress:, mock_overrides: resolved_overrides)
175
191
  else
176
192
  []
177
193
  end
@@ -266,7 +282,7 @@ module Bitfab
266
282
 
267
283
  # Process all replay items, optionally in parallel using threads.
268
284
  def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
269
- adapt_inputs = nil, include_db_branch_lease = false, on_progress: nil)
285
+ adapt_inputs = nil, include_db_branch_lease = false, on_progress: nil, mock_overrides: [])
270
286
  concurrency = max_concurrency || server_items.length
271
287
 
272
288
  # Reports running totals once per item as it settles. In the parallel
@@ -316,7 +332,7 @@ module Bitfab
316
332
  if concurrency <= 1
317
333
  server_items.map do |item|
318
334
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
319
- adapt_inputs, include_db_branch_lease)
335
+ adapt_inputs, include_db_branch_lease, mock_overrides:)
320
336
  report.call(result, item["traceId"], test_run_id)
321
337
  result
322
338
  end
@@ -333,7 +349,7 @@ module Bitfab
333
349
  break unless item
334
350
 
335
351
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
336
- adapt_inputs, include_db_branch_lease)
352
+ adapt_inputs, include_db_branch_lease, mock_overrides:)
337
353
  results_mutex.synchronize { results[idx] = result }
338
354
  report.call(result, item["traceId"], test_run_id)
339
355
  end
@@ -352,7 +368,7 @@ module Bitfab
352
368
  # than propagated, so one bad trace never aborts the whole replay run
353
369
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
354
370
  def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy,
355
- adapt_inputs = nil, include_db_branch_lease = false)
371
+ adapt_inputs = nil, include_db_branch_lease = false, mock_overrides: [])
356
372
  metrics = extract_server_item_metrics(server_item)
357
373
  # The server resolves a Neon preview branch per item during /replay/start
358
374
  # (only when include_db_branch_lease was sent). Release it in the +ensure+
@@ -365,18 +381,43 @@ module Bitfab
365
381
  span = http_client.get_external_span(server_item["externalSpanId"])
366
382
  item_data = extract_span_data(span)
367
383
 
384
+ # Fetch the span tree when the base strategy needs recorded outputs
385
+ # ("all"/"marked") OR when selective mock overrides are present. Overrides
386
+ # gate on the same non-root call-counter machinery the tree drives, so
387
+ # the tree must be fetched for them to fire even under mock: "none".
388
+ #
389
+ # Only mock: "all" needs every span's recorded output inline (it mocks
390
+ # every child), so it fetches an eager tree. "marked" and overrides mock
391
+ # only a few spans, so they fetch a payload-free tree (includeOutputs=
392
+ # false) and pull each mocked span's output lazily by externalSpanId,
393
+ # never dragging down outputs that no span consumes.
394
+ overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
395
+ include_outputs = mock_strategy == "all"
368
396
  mock_tree = nil
369
- if mock_strategy == "all" || mock_strategy == "marked"
397
+ if mock_strategy == "all" || mock_strategy == "marked" || overrides_present
370
398
  begin
371
- tree = http_client.get_span_tree(server_item["externalSpanId"])
399
+ tree = http_client.get_span_tree(server_item["externalSpanId"], include_outputs:)
372
400
  mock_tree = build_mock_tree(tree["root"] || {})
373
401
  rescue Exception => e # rubocop:disable Lint/RescueException
374
402
  raise if e.is_a?(SystemExit) || e.is_a?(SignalException)
375
- raise if mock_strategy == "all"
403
+ # "all" and overrides both depend on the tree: "all" mocks every span
404
+ # from it, and overrides gate on its call-counter machinery. If the
405
+ # fetch fails, surface the error on the item rather than silently
406
+ # running everything real with the overrides dropped. Only bare
407
+ # "marked" can fall back to real execution (its marked spans just
408
+ # re-run). Mirrors the Python SDK's has_overrides re-raise.
409
+ raise if mock_strategy == "all" || overrides_present
376
410
  mock_tree = nil
377
411
  end
378
412
  end
379
413
 
414
+ # Memoized per-item lazy fetcher, present only on the payload-free path
415
+ # (not eager "all", whose outputs are already inline). Passed to the
416
+ # replay context so resolve_recorded_output can pull a mocked span's
417
+ # output on demand, once per span even if read twice (base mock + an
418
+ # override's get_original_output).
419
+ fetch_span_output = (mock_tree && !include_outputs) ? build_span_output_fetcher(http_client) : nil
420
+
380
421
  adapt_ctx = {trace_id: server_item["traceId"], source_span_id: server_item["externalSpanId"]}
381
422
 
382
423
  execute_item(
@@ -389,6 +430,8 @@ module Bitfab
389
430
  input_source_trace_id: span["externalTraceId"],
390
431
  mock_strategy:,
391
432
  mock_tree:,
433
+ mock_overrides:,
434
+ fetch_span_output:,
392
435
  adapt_inputs:,
393
436
  adapt_ctx:,
394
437
  db_branch_lease: lease,
@@ -448,11 +491,18 @@ module Bitfab
448
491
  counter_key = "#{key}:#{name}"
449
492
  index = counters[counter_key] || 0
450
493
  counters[counter_key] = index + 1
451
- spans["#{counter_key}:#{index}"] = {
494
+ # externalSpanId is what the lazy path fetches the recorded output by
495
+ # when the tree came back payload-free (includeOutputs=false). output
496
+ # / outputMeta are copied ONLY when the node actually carries them, so
497
+ # the presence of the :output key distinguishes an inline nil output
498
+ # from a payload-free entry (mirrors TS's undefined-vs-null check).
499
+ entry = {
452
500
  source_span_id: node["sourceSpanId"],
453
- output: node["output"],
454
- output_meta: node["outputMeta"]
501
+ external_span_id: node["externalSpanId"]
455
502
  }
503
+ entry[:output] = node["output"] if node.key?("output")
504
+ entry[:output_meta] = node["outputMeta"] if node.key?("outputMeta")
505
+ spans["#{counter_key}:#{index}"] = entry
456
506
  end
457
507
  (node["children"] || []).each { |child| walk.call(child) }
458
508
  end
@@ -462,6 +512,27 @@ module Bitfab
462
512
  spans
463
513
  end
464
514
 
515
+ # Build a memoized lazy fetcher for a single replay item. The returned
516
+ # lambda takes an externalSpanId and returns that span's deserialized
517
+ # recorded output, fetching it via get_external_span at most once per id
518
+ # (a span read twice - as a base "marked" mock and via an override's
519
+ # get_original_output - fetches once). The cache is per item: each item
520
+ # replays synchronously on its own thread, so no lock is needed.
521
+ def build_span_output_fetcher(http_client)
522
+ cache = {}
523
+ lambda do |external_span_id|
524
+ return cache[external_span_id] if cache.key?(external_span_id)
525
+
526
+ span = http_client.get_external_span(external_span_id)
527
+ span_data = (span["rawData"] || {})["span_data"] || {}
528
+ # Prefer the Ruby Marshal payload (output_serialized) written by this
529
+ # SDK; fall back to another SDK's output_meta, then the raw JSON output.
530
+ meta = span_data["output_serialized"]
531
+ meta = span_data["output_meta"] if meta.nil? || meta == ""
532
+ cache[external_span_id] = Serialize.deserialize_output(span_data["output"], meta)
533
+ end
534
+ end
535
+
465
536
  # Extract input/output data from an external span's rawData.
466
537
  def extract_span_data(span)
467
538
  raw_data = span["rawData"] || {}
@@ -505,8 +576,9 @@ module Bitfab
505
576
 
506
577
  # Execute a single replay item: deserialize inputs, call method with replay context.
507
578
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
508
- input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, adapt_inputs: nil, adapt_ctx: nil,
509
- db_branch_lease: nil, source_bitfab_trace_id: nil, db_snapshot_ref: nil)
579
+ input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
580
+ fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil,
581
+ db_snapshot_ref: nil)
510
582
  args, kwargs = Serialize.deserialize_inputs(item)
511
583
 
512
584
  fn_result = nil
@@ -525,6 +597,8 @@ module Bitfab
525
597
  trace_id: sdk_trace_id,
526
598
  mock_tree:,
527
599
  mock_strategy:,
600
+ mock_overrides:,
601
+ fetch_span_output:,
528
602
  pending_persistence:,
529
603
  db_branch_lease:,
530
604
  source_bitfab_trace_id:
@@ -182,6 +182,27 @@ module Bitfab
182
182
  Marshal.load(Base64.strict_decode64(encoded)) # rubocop:disable Security/MarshalLoad
183
183
  end
184
184
 
185
+ # Deserialize a recorded span output. Prefers the Ruby-side Marshal+Base64
186
+ # `meta` when present (type-preserving); falls back to the raw JSON output
187
+ # when meta is absent or a shape Ruby cannot reconstruct (e.g. the
188
+ # superjson/jsonpickle meta another SDK's span may carry). Shared by the
189
+ # inline mock-tree path and the lazy per-span fetch so both deserialize
190
+ # identically.
191
+ #
192
+ # @param raw_output [Object] the human-readable JSON output
193
+ # @param meta [String, nil] the Marshal+Base64 metadata, if any
194
+ # @return [Object] the reconstructed output, or raw_output on fallback
195
+ def deserialize_output(raw_output, meta)
196
+ if meta.is_a?(String) && !meta.empty?
197
+ begin
198
+ return unmarshal_value(meta)
199
+ rescue
200
+ # Fall through to the raw JSON output
201
+ end
202
+ end
203
+ raw_output
204
+ end
205
+
185
206
  # Deserialize replay inputs from a span's data into [args, kwargs].
186
207
  #
187
208
  # Prefers Marshal-serialized `inputSerialized` for type preservation,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.23.8"
4
+ VERSION = "0.29.0"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "bitfab/serialize"
9
9
  require_relative "bitfab/db_snapshot"
10
10
  require_relative "bitfab/span_context"
11
11
  require_relative "bitfab/http_client"
12
+ require_relative "bitfab/mock_override"
12
13
  require_relative "bitfab/replay"
13
14
  require_relative "bitfab/replay_environment"
14
15
  require_relative "bitfab/client"
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.23.8
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -121,6 +121,7 @@ files:
121
121
  - lib/bitfab/constants.rb
122
122
  - lib/bitfab/db_snapshot.rb
123
123
  - lib/bitfab/http_client.rb
124
+ - lib/bitfab/mock_override.rb
124
125
  - lib/bitfab/replay.rb
125
126
  - lib/bitfab/replay_environment.rb
126
127
  - lib/bitfab/serialize.rb