bitfab 0.23.8 → 0.29.1

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: 1f26df032729a0c1fbad20c807e1581ec1c68bb9019e0b8f32a85237c785a6ed
4
+ data.tar.gz: 6acf7a7e153565cf4fb272b651f2740b4d3cb5722b67b6508552aa1b2e81d042
5
5
  SHA512:
6
- metadata.gz: c76023257bcdd959cb49d6881faf05e85013b2d30c02826fd57a2a2aa8a72d72d7f8f10716f9f4b2d850a84dfb4a6ef7151077b88f43e2e1e7bd5b4c81ed23ff
7
- data.tar.gz: 37fb2e1215527c780253dd35e10e4ff01d735336d068d5d8ac16a75d4fcc17e1749f7718cf8ffc550e965a27a9f0ee8fb49b2b847724c10a1ae031f0f1470574
6
+ metadata.gz: 5fdcbb34b7174b5308d46405c95735d0a986db51f1af6ddbeb26613ebcdd9c35fade8a40553b1f88b78dc699261146d8cd9d4d7abffda560fbf06f40527717ce
7
+ data.tar.gz: ea2cd76d972b4b67f1554e1baf28b2aeeaf8560d1329eeff1f6df721273ce6f6e27193d20e0c0995ea77077544a5ab6f64cbb06908276eb8a27c6a6d590171b7
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
@@ -79,7 +89,18 @@ module Bitfab
79
89
  # @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
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
- # { trace_id:, source_span_id: }, and returns [new_args, new_kwargs].
92
+ # { original_trace_id:, original_span_id: } (with deprecated
93
+ # source_trace_id/source_span_id aliases), and returns [new_args, new_kwargs].
94
+ # @param mock_override [Hash, Array<Hash>, nil] optional selective mock
95
+ # override(s), each a { match:, value: } hash. match is a callable:
96
+ # match.call(node) selects spans to substitute (node is
97
+ # { trace_function_key:, span_name:, type:, original_span_id: }). value is
98
+ # EITHER a flat value injected directly OR a callable invoked with
99
+ # { node:, inputs:, get_original_output: }; its result becomes the span's
100
+ # output (full replacement), so downstream real code runs against it. The
101
+ # first matching override wins. Per-call overrides take precedence over
102
+ # those registered via register_mock_override, and both take precedence
103
+ # over the base mock strategy. The root span is never overridden.
83
104
  # @param on_progress [#call, nil] optional callback invoked once per item as
84
105
  # it finishes, with a running-totals hash { completed:, total:, succeeded:,
85
106
  # errored: }. Use it to render replay progress (e.g. a terminal progress
@@ -89,15 +110,84 @@ module Bitfab
89
110
  # @return [Hash] with :items, :test_run_id, :test_run_url
90
111
  def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
91
112
  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)
113
+ adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
93
114
  Replay.run(
94
115
  self, receiver, method_name,
95
116
  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:,
117
+ code_change_description:, code_change_files:, experiment_group_id:, dataset_id:, mock:, adapt_inputs:,
118
+ mock_override:, environment:,
97
119
  on_progress:
98
120
  )
99
121
  end
100
122
 
123
+ # Register a mock override applied to every subsequent replay on this
124
+ # client, so downstream real code runs against a value you supply for the
125
+ # matched span. Instance-scoped (no global state); call
126
+ # clear_mock_overrides to reset. Per-call replay(mock_override:) overrides
127
+ # take precedence, and both take precedence over the base mock strategy.
128
+ #
129
+ # Accepts either the (match, value) positional form or the keyword form
130
+ # (a { match:, value: } hash is also accepted). `match` must be callable;
131
+ # `value` is either a flat value injected directly or a callable invoked
132
+ # with the ctx hash.
133
+ #
134
+ # @example keyword form (primary), flat value
135
+ # client.register_mock_override(
136
+ # match: ->(node) { node[:trace_function_key] == "classify-intent" },
137
+ # value: {label: "refund"}
138
+ # )
139
+ #
140
+ # @example positional form (equivalent), callable value
141
+ # client.register_mock_override(
142
+ # ->(node) { node[:trace_function_key] == "classify-intent" },
143
+ # ->(ctx) { {label: "refund", inputs: ctx[:inputs]} }
144
+ # )
145
+ #
146
+ # @param positional [Array] either (match, value) or a single
147
+ # { match:, value: } hash
148
+ # @param match [#call, nil] keyword form matcher
149
+ # @param value [Object, #call, nil] keyword form injected value or producer
150
+ # @return [void]
151
+ def register_mock_override(*positional, match: nil, value: VALUE_UNSET)
152
+ if match.nil? && value.equal?(VALUE_UNSET)
153
+ if positional.length == 1 && positional[0].is_a?(Hash)
154
+ override = positional[0]
155
+ match = override[:match]
156
+ # Distinguish an omitted :value from an explicit `value: nil`: the
157
+ # key's presence, not a nil read, is what marks it provided.
158
+ value = override.key?(:value) ? override[:value] : VALUE_UNSET
159
+ elsif positional.length == 2
160
+ match, value = positional
161
+ end
162
+ end
163
+
164
+ unless match.respond_to?(:call)
165
+ raise ArgumentError,
166
+ "register_mock_override requires a callable match. Pass (match, value) " \
167
+ "positionally, as keywords (match:, value:), or as a { match:, value: } hash. " \
168
+ "value may be a flat value or a callable."
169
+ end
170
+ # A forgotten value must not silently inject nil. An explicit nil is a
171
+ # legitimate injected value and passes this guard.
172
+ if value.equal?(VALUE_UNSET)
173
+ raise ArgumentError,
174
+ "register_mock_override requires a value (the second argument, or " \
175
+ "value:). It may be a flat value or a callable; pass value: nil " \
176
+ "explicitly to inject nil."
177
+ end
178
+
179
+ @mock_overrides << {match:, value:}
180
+ nil
181
+ end
182
+
183
+ # Remove all overrides registered via register_mock_override.
184
+ #
185
+ # @return [void]
186
+ def clear_mock_overrides
187
+ @mock_overrides.clear
188
+ nil
189
+ end
190
+
101
191
  # Get a function wrapper bound to a specific trace function key.
102
192
  #
103
193
  # This provides a fluent API for binding a trace_function_key once and
@@ -192,7 +282,8 @@ module Bitfab
192
282
  call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
193
283
  if call_index
194
284
  mocked_output = check_mock_replay(
195
- replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
285
+ replay_ctx, trace_function_key, span_name, call_index,
286
+ span_type:, args:, kwargs:, mock_on_replay:
196
287
  )
197
288
  if mocked_output != MOCK_REPLAY_MISS
198
289
  send_mocked_span(
@@ -285,6 +376,9 @@ module Bitfab
285
376
  {
286
377
  neon_branch_id: lease["neonBranchId"],
287
378
  snapshot_timestamp: lease["snapshotTimestamp"],
379
+ original_trace_id: replay_ctx[:source_bitfab_trace_id],
380
+ # Deprecated wire alias, kept so this SDK still reports usage
381
+ # against servers that predate the rename.
288
382
  source_trace_id: replay_ctx[:source_bitfab_trace_id],
289
383
  accessed: replay_ctx[:db_snapshot_accessed] == true
290
384
  }
@@ -452,10 +546,11 @@ module Bitfab
452
546
  # db_snapshot_usage: replay DB branch usage record, present only when a
453
547
  # lease was attached to the replay item. Serialized as `db_snapshot_usage`
454
548
  # on the raw trace so the server can stamp the trace's metadata at ingest:
455
- # { neon_branch_id:, snapshot_timestamp: (optional), source_trace_id:
456
- # (optional), accessed: } with :accessed true if customer code obtained
457
- # the branch URL and false if it ignored it. nil outside replay or when no
458
- # lease was attached, in which case the key is omitted entirely.
549
+ # { neon_branch_id:, snapshot_timestamp: (optional), original_trace_id:
550
+ # (optional, with its deprecated source_trace_id alias), accessed: } with
551
+ # :accessed true if customer code obtained the branch URL and false if it
552
+ # ignored it. nil outside replay or when no lease was attached, in which
553
+ # case the key is omitted entirely.
459
554
  def send_trace_completion(trace_function_key:, trace_id:, started_at:, ended_at:, db_snapshot_usage: nil)
460
555
  trace_state = TraceState.get(trace_id)
461
556
  trace_started_at = trace_state&.dig(:started_at) || started_at
@@ -483,8 +578,11 @@ module Bitfab
483
578
  if db_snapshot_usage[:snapshot_timestamp]
484
579
  usage["snapshot_timestamp"] = db_snapshot_usage[:snapshot_timestamp]
485
580
  end
486
- if db_snapshot_usage[:source_trace_id]
487
- usage["source_trace_id"] = db_snapshot_usage[:source_trace_id]
581
+ if db_snapshot_usage[:original_trace_id]
582
+ usage["original_trace_id"] = db_snapshot_usage[:original_trace_id]
583
+ # Deprecated wire alias, kept so this SDK still reports usage
584
+ # against servers that predate the rename.
585
+ usage["source_trace_id"] = db_snapshot_usage[:original_trace_id]
488
586
  end
489
587
  usage["accessed"] = db_snapshot_usage[:accessed]
490
588
  raw_trace["db_snapshot_usage"] = usage
@@ -617,10 +715,53 @@ module Bitfab
617
715
  call_index
618
716
  end
619
717
 
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:)
718
+ # Decide whether this child span should be short-circuited during replay.
719
+ # Returns MOCK_REPLAY_MISS when the span should run real code, otherwise
720
+ # returns the value to inject (a selective override's produced value, or the
721
+ # deserialized historical output under the base "all"/"marked" strategy).
722
+ #
723
+ # Precedence: a matching mock override wins over the base strategy (per-call
724
+ # overrides already precede registered ones in the resolved list). A span no
725
+ # override matches falls through to the marked/all logic unchanged. An
726
+ # override that matches ALWAYS injects, even when it returns nil, so a
727
+ # legitimately-nil injected value is never mistaken for a miss.
728
+ def check_mock_replay(replay_ctx, trace_function_key, span_name, call_index, span_type:, args:, kwargs:, mock_on_replay:)
729
+ mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
730
+
731
+ # 1) Selective overrides. The matcher runs on structural metadata only;
732
+ # the recorded output is fetched (lazily, by externalSpanId) only if the
733
+ # processor actually asks for it via get_original_output, so a pure
734
+ # synthetic override triggers zero output fetches.
735
+ overrides = replay_ctx[:mock_overrides]
736
+ if overrides&.any?
737
+ node = {
738
+ trace_function_key:,
739
+ span_name:,
740
+ type: span_type,
741
+ original_span_id: mock_entry && mock_entry[:source_span_id]
742
+ }
743
+ override = overrides.find { |o| o[:match].call(node) }
744
+ if override
745
+ value = override[:value]
746
+ # value is EITHER a flat value (injected directly) OR a callable
747
+ # invoked with the ctx hash. The result IS the span's output (full
748
+ # replacement). nil is a legitimate injected value: because a matching
749
+ # override always injects, execute_span short-circuits even when this
750
+ # is nil (nil != MOCK_REPLAY_MISS). To inject a proc as the literal
751
+ # output, wrap it in a callable value.
752
+ return value unless value.respond_to?(:call)
753
+
754
+ get_original_output = lambda do
755
+ unless mock_entry
756
+ raise "No recorded span to source output for '#{trace_function_key}'."
757
+ end
758
+ resolve_recorded_output(mock_entry, replay_ctx)
759
+ end
760
+ return value.call({node:, inputs: args, kwargs:, get_original_output:})
761
+ end
762
+ end
763
+
764
+ # 2) Base strategy: replay recorded output for eligible spans.
624
765
  strategy = replay_ctx[:mock_strategy]
625
766
  case strategy
626
767
  when "marked"
@@ -631,25 +772,40 @@ module Bitfab
631
772
  return MOCK_REPLAY_MISS
632
773
  end
633
774
 
634
- mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
635
775
  return MOCK_REPLAY_MISS unless mock_entry
636
776
 
637
- output = mock_entry[:output]
638
- output_meta = mock_entry[:output_meta]
777
+ resolve_recorded_output(mock_entry, replay_ctx)
778
+ end
639
779
 
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
780
+ # Resolve a mock-tree entry's recorded output. Prefers an inline output when
781
+ # the tree carried one (eager "all", or an older server that ignores
782
+ # includeOutputs and returns outputs); otherwise lazily fetches it by
783
+ # externalSpanId through the per-item memoized fetcher on the replay
784
+ # context, so only the spans actually mocked pull their output.
785
+ #
786
+ # Deserialization is type-preserving when Ruby-side Marshal+Base64 metadata
787
+ # is present, falling back to the raw JSON output when the meta is a shape
788
+ # Ruby cannot reconstruct (e.g. superjson/jsonpickle from another SDK).
789
+ def resolve_recorded_output(mock_entry, replay_ctx)
790
+ # Presence of the :output (or :output_meta) key means the tree carried the
791
+ # output inline - including a legitimately nil inline output. A
792
+ # payload-free entry has neither key and is fetched lazily below.
793
+ if mock_entry.key?(:output) || mock_entry.key?(:output_meta)
794
+ return Serialize.deserialize_output(mock_entry[:output], mock_entry[:output_meta])
795
+ end
796
+
797
+ fetcher = replay_ctx[:fetch_span_output]
798
+ external_span_id = mock_entry[:external_span_id]
799
+ if fetcher && external_span_id
800
+ return fetcher.call(external_span_id)
650
801
  end
651
802
 
652
- output
803
+ # No inline output and nothing to lazily fetch. This is a span that never
804
+ # recorded an output (e.g. an errored or incomplete child): inject nil
805
+ # rather than raising, so it does not fail the whole replay item. Mirrors
806
+ # the Python and TypeScript SDKs, which return the (absent) recorded output
807
+ # as null here.
808
+ nil
653
809
  end
654
810
 
655
811
  # 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
@@ -101,14 +110,19 @@ module Bitfab
101
110
  # @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
102
111
  # onto the method's current signature when its shape changed after the
103
112
  # traces were captured. Receives (args, kwargs, ctx) where ctx is
104
- # { trace_id:, source_span_id: }, and returns [new_args, new_kwargs]. Runs
105
- # per item inside the same rescue as the method, so a raising adapter sets
106
- # that item's :error rather than crashing the run.
113
+ # { original_trace_id:, original_span_id: } (with deprecated
114
+ # source_trace_id/source_span_id aliases), and returns [new_args, new_kwargs].
115
+ # Runs per item inside the same rescue as the method, so a raising adapter
116
+ # sets that item's :error rather than crashing the run.
107
117
  # @param on_progress [#call, nil] optional callback invoked once per item as
108
118
  # it finishes, with a running-totals hash { completed:, total:, succeeded:,
109
- # errored:, item: } where item is { trace_id:, error:, duration_ms: } for
110
- # the single item that just settled. trace_id is the SOURCE (historical)
111
- # trace that was replayed, error is that item's replay error or nil, and
119
+ # errored:, item: } where item is { trace_id:, original_trace_id:,
120
+ # original_span_id:, error:, duration_ms: } for the single item that just
121
+ # settled (source_trace_id/source_span_id remain as deprecated aliases).
122
+ # trace_id is the new server replay trace id, written in after the run
123
+ # completes (nil during progress callbacks); original_trace_id is the
124
+ # ORIGINAL (historical) trace that was replayed, error is that item's replay
125
+ # error or nil, and
112
126
  # duration_ms is how long that one trace took to replay. Use it to
113
127
  # render replay progress (e.g. a per-trace log). A raising callback never
114
128
  # crashes the run.
@@ -116,7 +130,7 @@ module Bitfab
116
130
  def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil,
117
131
  max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil,
118
132
  dataset_id: nil, mock: "marked",
119
- adapt_inputs: nil, environment: nil, on_progress: nil)
133
+ adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil)
120
134
  unless MOCK_STRATEGIES.include?(mock.to_s)
121
135
  raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
122
136
  end
@@ -148,6 +162,13 @@ module Bitfab
148
162
 
149
163
  http_client = client.instance_variable_get(:@http_client)
150
164
 
165
+ # Resolved override list: per-call overrides FIRST (they win), then the
166
+ # client's registered overrides. First matching override wins downstream,
167
+ # so this ordering makes per-call take precedence over registered, and
168
+ # both take precedence over the base mock strategy.
169
+ registered_overrides = client.instance_variable_get(:@mock_overrides) || []
170
+ resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides
171
+
151
172
  # limit is meaningless with explicit trace_ids (the ID list determines
152
173
  # the count), so it's omitted from the request entirely.
153
174
  effective_limit = trace_ids ? nil : (limit || 5)
@@ -171,17 +192,16 @@ module Bitfab
171
192
 
172
193
  result_items = if server_items.any?
173
194
  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:)
195
+ adapt_inputs, include_db_branch_lease, on_progress:, mock_overrides: resolved_overrides)
175
196
  else
176
197
  []
177
198
  end
178
199
 
179
200
  # Every item joined its own trace-persistence threads (span uploads +
180
201
  # completion) in execute_item, so all replay traces are on the server
181
- # by now: no flush needed, and complete_replay's trace-ID mapping is
182
- # deterministic. complete_replay failures propagate: a missing mapping
183
- # means verdicts can't be persisted, which callers must hear about
184
- # loudly.
202
+ # by now: no flush needed. complete_replay finalizes the run and returns
203
+ # the token/diagnostic mapping; its failures propagate loudly because a
204
+ # run that never completed can't be finalized.
185
205
  complete_response = http_client.complete_replay(test_run_id)
186
206
  trace_id_map = complete_response&.dig("traceIds")
187
207
  # Per-replay-trace token usage keyed by server trace id: the REPLAYED
@@ -189,66 +209,85 @@ module Bitfab
189
209
  # item's :tokens.
190
210
  replay_tokens = complete_response&.dig("tokens") || {}
191
211
 
192
- if trace_id_map.nil?
193
- # Older servers don't return the mapping. Preserve the legacy
194
- # nil-trace_id behavior but say why.
195
- warn "Bitfab: server did not return replay trace IDs; item trace_id " \
196
- "will be nil (server upgrade required for verdict persistence)"
197
- result_items.each { |item| item[:trace_id] = nil }
198
- else
199
- # Map each item's locally-generated trace ID to the server's trace
200
- # row ID. A completed item with no mapping means its trace was sent
201
- # but the server has no record: a nil trace_id blocks verdict
202
- # persistence and the Studio experiments view downstream, so this
203
- # must never be silent.
204
- #
205
- # Severity splits on scope:
206
- # - ALL completed items missing: systemic (the replayed method is
207
- # not traced, or uploads are wholesale broken). Raise; the run's
208
- # results are unusable for persistence.
209
- # - SOME completed items missing: per-item upload failure (transient
210
- # network blip, one oversized payload). Nil those items and warn
211
- # loudly, but return the run so callers can persist verdicts for
212
- # the items that landed.
212
+ # trace_id_map maps each item's client-side correlation id (:_sdk_trace_id,
213
+ # which tagged that item's spans during the run) to the server's trace row
214
+ # id. We use it to write the real server replay trace id into item[:trace_id]
215
+ # now that the row exists, attach each item's server-aggregated token usage,
216
+ # and detect a systemic upload failure early. Verdict persistence does NOT
217
+ # use this map: it is keyed by the original-trace lineage (:original_trace_id +
218
+ # test_run_id), which needs no client-held server id. Older servers that omit
219
+ # the map yield no replay tokens and leave item[:trace_id] nil.
220
+ unless trace_id_map.nil?
213
221
  missing = []
214
222
  completed_count = 0
215
223
  result_items.each do |item|
216
- next unless item[:trace_id]
217
-
218
- mapped = trace_id_map[item[:trace_id]]
224
+ local_id = item[:_sdk_trace_id]
225
+ mapped = local_id && trace_id_map[local_id]
226
+ # Write the real server replay trace id in as it comes back; the item
227
+ # held nil until now (the client correlation id is never surfaced).
228
+ item[:trace_id] = mapped
219
229
  if item[:error].nil?
220
230
  completed_count += 1
221
- missing << item[:trace_id] if mapped.nil?
231
+ missing << local_id if mapped.nil?
222
232
  end
223
- # Pull this item's replayed-run tokens by its server trace id, before
224
- # :trace_id is overwritten with that id below.
225
233
  item[:tokens] = normalize_tokens(replay_tokens[mapped]) if mapped
226
- item[:trace_id] = mapped
227
234
  end
228
- if missing.any?
235
+ # ALL completed items missing: systemic (the replayed method is not
236
+ # traced, or uploads are wholesale broken). Raise; the run's traces
237
+ # never persisted, so nothing can be labeled.
238
+ if completed_count.positive? && missing.length == completed_count
229
239
  trace_count = complete_response["traceCount"]
230
240
  server_count = trace_count.nil? ? "" : " The server persisted #{trace_count} trace(s) for this run."
231
- if missing.length == completed_count
232
- raise "Replay completed but the server has no persisted trace for " \
233
- "any of the #{completed_count} completed item(s) " \
234
- "(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \
235
- "joined, so either the uploads failed or the replayed method is " \
236
- "not traced (no root span was emitted)."
237
- end
241
+ raise "Replay completed but the server has no persisted trace for " \
242
+ "any of the #{completed_count} completed item(s) " \
243
+ "(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \
244
+ "joined, so either the uploads failed or the replayed method is " \
245
+ "not traced (no root span was emitted)."
246
+ end
247
+ # SOME completed items missing: per-item upload failure. Warn, but
248
+ # return the run; the items that landed can still be labeled.
249
+ if missing.any?
238
250
  warn "Bitfab: server has no persisted trace for #{missing.length} of " \
239
251
  "#{completed_count} completed replay item(s) " \
240
- "(test_run_id #{test_run_id}).#{server_count} Their trace_id is nil " \
241
- "and verdicts cannot be persisted for them. Missing: #{missing.join(", ")}"
252
+ "(test_run_id #{test_run_id}). Their replay token usage is " \
253
+ "unavailable and they cannot be labeled."
242
254
  end
243
255
  end
244
256
 
245
- replay_result = {
257
+ # Strip the internal correlation handle so returned items expose only the
258
+ # public shape (runs against older servers that omit the map too).
259
+ result_items.each { |item| item.delete(:_sdk_trace_id) }
260
+
261
+ result = {
246
262
  items: result_items,
247
263
  test_run_id:,
248
264
  test_run_url: "#{client.service_url}#{test_run_url}"
249
265
  }
250
- write_replay_result_file(replay_result)
251
- replay_result
266
+ # Persist the enriched result two ways so the Bitfab plugin never has to
267
+ # parse the replay's stdout (which a dependency's logging can corrupt):
268
+ # write it to BITFAB_REPLAY_RESULT_PATH when the plugin set that env var,
269
+ # and stream a terminal "complete" progress event. The plugin prefers the
270
+ # streamed event and falls back to the file. The event routes through
271
+ # on_progress so only progress-reporting runs emit it.
272
+ write_replay_result_file(result)
273
+ if on_progress
274
+ errored = result_items.count { |item| !item[:error].nil? }
275
+ total = result_items.length
276
+ begin
277
+ on_progress.call({
278
+ type: "complete",
279
+ test_run_id:,
280
+ completed: total,
281
+ total:,
282
+ succeeded: total - errored,
283
+ errored:,
284
+ result:
285
+ })
286
+ rescue => e
287
+ warn "Bitfab: replay on_progress callback raised: #{e.message}"
288
+ end
289
+ end
290
+ result
252
291
  end
253
292
 
254
293
  def write_replay_result_file(result)
@@ -266,7 +305,7 @@ module Bitfab
266
305
 
267
306
  # Process all replay items, optionally in parallel using threads.
268
307
  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)
308
+ adapt_inputs = nil, include_db_branch_lease = false, on_progress: nil, mock_overrides: [])
270
309
  concurrency = max_concurrency || server_items.length
271
310
 
272
311
  # Reports running totals once per item as it settles. In the parallel
@@ -280,11 +319,13 @@ module Bitfab
280
319
  succeeded = 0
281
320
  errored = 0
282
321
  # Each event carries the single item that just settled so a progress UI
283
- # can render per-trace pass/fail as the run streams. trace_id is the
284
- # SOURCE (historical) trace that was replayed, taken from the server
285
- # item: the result's own :trace_id at this stage is the new replay
286
- # trace id (assigned in run() after complete_replay), not the source.
287
- report = lambda do |result, source_trace_id, test_run_id|
322
+ # can render per-trace pass/fail as the run streams. The item's :trace_id
323
+ # is nil at this stage: the server replay trace id isn't known until run()
324
+ # writes it in after complete_replay, and the client correlation id is
325
+ # never surfaced. original_trace_id (the historical trace being replayed,
326
+ # taken from the server item) is what a UI keys on to identify what just
327
+ # settled. source_trace_id/source_span_id are kept as deprecated aliases.
328
+ report = lambda do |result, original_trace_id, original_span_id, test_run_id|
288
329
  return unless on_progress
289
330
 
290
331
  progress_mutex.synchronize do
@@ -295,8 +336,12 @@ module Bitfab
295
336
  on_progress.call({
296
337
  test_run_id:, completed:, total:, succeeded:, errored:,
297
338
  item: {
298
- trace_id: source_trace_id,
299
- replay_trace_id: result[:trace_id],
339
+ trace_id: result[:trace_id],
340
+ original_trace_id:,
341
+ original_span_id:,
342
+ # Deprecated aliases for original_trace_id/original_span_id.
343
+ source_trace_id: original_trace_id,
344
+ source_span_id: original_span_id,
300
345
  input: result[:input],
301
346
  result: result[:result],
302
347
  original_output: result[:original_output],
@@ -316,8 +361,8 @@ module Bitfab
316
361
  if concurrency <= 1
317
362
  server_items.map do |item|
318
363
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
319
- adapt_inputs, include_db_branch_lease)
320
- report.call(result, item["traceId"], test_run_id)
364
+ adapt_inputs, include_db_branch_lease, mock_overrides:)
365
+ report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
321
366
  result
322
367
  end
323
368
  else
@@ -333,9 +378,9 @@ module Bitfab
333
378
  break unless item
334
379
 
335
380
  result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
336
- adapt_inputs, include_db_branch_lease)
381
+ adapt_inputs, include_db_branch_lease, mock_overrides:)
337
382
  results_mutex.synchronize { results[idx] = result }
338
- report.call(result, item["traceId"], test_run_id)
383
+ report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id)
339
384
  end
340
385
  end
341
386
  end
@@ -352,8 +397,13 @@ module Bitfab
352
397
  # than propagated, so one bad trace never aborts the whole replay run
353
398
  # (mirrors the TypeScript and Python SDKs' per-item rescue).
354
399
  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)
400
+ adapt_inputs = nil, include_db_branch_lease = false, mock_overrides: [])
356
401
  metrics = extract_server_item_metrics(server_item)
402
+ # The ORIGINAL (historical) trace/span this item replays. Canonical
403
+ # keys are originalTraceId/originalSpanId; older servers send them under
404
+ # the deprecated sourceTraceId/sourceSpanId aliases (see *_of helpers).
405
+ original_trace_id = original_trace_id_of(server_item)
406
+ original_span_id = original_span_id_of(server_item)
357
407
  # The server resolves a Neon preview branch per item during /replay/start
358
408
  # (only when include_db_branch_lease was sent). Release it in the +ensure+
359
409
  # below so any raise (span fetch, mock-tree build, or the replayed
@@ -362,22 +412,53 @@ module Bitfab
362
412
  # lease (env.active? is false for those).
363
413
  lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
364
414
 
365
- span = http_client.get_external_span(server_item["externalSpanId"])
415
+ span = http_client.get_external_span(original_span_id)
366
416
  item_data = extract_span_data(span)
367
417
 
418
+ # Fetch the span tree when the base strategy needs recorded outputs
419
+ # ("all"/"marked") OR when selective mock overrides are present. Overrides
420
+ # gate on the same non-root call-counter machinery the tree drives, so
421
+ # the tree must be fetched for them to fire even under mock: "none".
422
+ #
423
+ # Only mock: "all" needs every span's recorded output inline (it mocks
424
+ # every child), so it fetches an eager tree. "marked" and overrides mock
425
+ # only a few spans, so they fetch a payload-free tree (includeOutputs=
426
+ # false) and pull each mocked span's output lazily by externalSpanId,
427
+ # never dragging down outputs that no span consumes.
428
+ overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
429
+ include_outputs = mock_strategy == "all"
368
430
  mock_tree = nil
369
- if mock_strategy == "all" || mock_strategy == "marked"
431
+ if mock_strategy == "all" || mock_strategy == "marked" || overrides_present
370
432
  begin
371
- tree = http_client.get_span_tree(server_item["externalSpanId"])
433
+ tree = http_client.get_span_tree(original_span_id, include_outputs:)
372
434
  mock_tree = build_mock_tree(tree["root"] || {})
373
435
  rescue Exception => e # rubocop:disable Lint/RescueException
374
436
  raise if e.is_a?(SystemExit) || e.is_a?(SignalException)
375
- raise if mock_strategy == "all"
437
+ # "all" and overrides both depend on the tree: "all" mocks every span
438
+ # from it, and overrides gate on its call-counter machinery. If the
439
+ # fetch fails, surface the error on the item rather than silently
440
+ # running everything real with the overrides dropped. Only bare
441
+ # "marked" can fall back to real execution (its marked spans just
442
+ # re-run). Mirrors the Python SDK's has_overrides re-raise.
443
+ raise if mock_strategy == "all" || overrides_present
376
444
  mock_tree = nil
377
445
  end
378
446
  end
379
447
 
380
- adapt_ctx = {trace_id: server_item["traceId"], source_span_id: server_item["externalSpanId"]}
448
+ # Memoized per-item lazy fetcher, present only on the payload-free path
449
+ # (not eager "all", whose outputs are already inline). Passed to the
450
+ # replay context so resolve_recorded_output can pull a mocked span's
451
+ # output on demand, once per span even if read twice (base mock + an
452
+ # override's get_original_output).
453
+ fetch_span_output = (mock_tree && !include_outputs) ? build_span_output_fetcher(http_client) : nil
454
+
455
+ adapt_ctx = {
456
+ original_trace_id:,
457
+ original_span_id:,
458
+ # Deprecated aliases for original_trace_id/original_span_id.
459
+ source_trace_id: original_trace_id,
460
+ source_span_id: original_span_id
461
+ }
381
462
 
382
463
  execute_item(
383
464
  item_data,
@@ -389,14 +470,16 @@ module Bitfab
389
470
  input_source_trace_id: span["externalTraceId"],
390
471
  mock_strategy:,
391
472
  mock_tree:,
473
+ mock_overrides:,
474
+ fetch_span_output:,
392
475
  adapt_inputs:,
393
476
  adapt_ctx:,
394
477
  db_branch_lease: lease,
395
- source_bitfab_trace_id: server_item["traceId"],
478
+ source_bitfab_trace_id: original_trace_id,
396
479
  db_snapshot_ref: server_item["dbSnapshotRef"]
397
480
  )
398
481
  rescue => e
399
- warn "Bitfab: replay item for span #{server_item["externalSpanId"]} failed before execution: #{e.message}"
482
+ warn "Bitfab: replay item for span #{original_span_id} failed before execution: #{e.message}"
400
483
  {
401
484
  input: [],
402
485
  result: nil,
@@ -406,12 +489,28 @@ module Bitfab
406
489
  tokens: metrics&.dig(:tokens),
407
490
  model: metrics&.dig(:model),
408
491
  trace_id: nil,
492
+ original_trace_id:,
493
+ original_span_id:,
494
+ # Deprecated aliases for original_trace_id/original_span_id.
495
+ source_trace_id: original_trace_id,
496
+ source_span_id: original_span_id,
409
497
  db_snapshot_ref: server_item["dbSnapshotRef"]
410
498
  }
411
499
  ensure
412
500
  release_db_branch_lease(http_client, lease) if lease
413
501
  end
414
502
 
503
+ # The ORIGINAL (historical) trace/span an item replays. Canonical server
504
+ # keys are originalTraceId/originalSpanId; older servers send them under
505
+ # the deprecated sourceTraceId/sourceSpanId aliases.
506
+ def original_trace_id_of(server_item)
507
+ server_item["originalTraceId"] || server_item["sourceTraceId"]
508
+ end
509
+
510
+ def original_span_id_of(server_item)
511
+ server_item["originalSpanId"] || server_item["sourceSpanId"]
512
+ end
513
+
415
514
  # Delete the per-item Neon preview branch. Best-effort: a failure is warned
416
515
  # but never raised: the server-side TTL janitor reaps orphans.
417
516
  def release_db_branch_lease(http_client, lease)
@@ -448,11 +547,18 @@ module Bitfab
448
547
  counter_key = "#{key}:#{name}"
449
548
  index = counters[counter_key] || 0
450
549
  counters[counter_key] = index + 1
451
- spans["#{counter_key}:#{index}"] = {
550
+ # externalSpanId is what the lazy path fetches the recorded output by
551
+ # when the tree came back payload-free (includeOutputs=false). output
552
+ # / outputMeta are copied ONLY when the node actually carries them, so
553
+ # the presence of the :output key distinguishes an inline nil output
554
+ # from a payload-free entry (mirrors TS's undefined-vs-null check).
555
+ entry = {
452
556
  source_span_id: node["sourceSpanId"],
453
- output: node["output"],
454
- output_meta: node["outputMeta"]
557
+ external_span_id: node["externalSpanId"]
455
558
  }
559
+ entry[:output] = node["output"] if node.key?("output")
560
+ entry[:output_meta] = node["outputMeta"] if node.key?("outputMeta")
561
+ spans["#{counter_key}:#{index}"] = entry
456
562
  end
457
563
  (node["children"] || []).each { |child| walk.call(child) }
458
564
  end
@@ -462,6 +568,27 @@ module Bitfab
462
568
  spans
463
569
  end
464
570
 
571
+ # Build a memoized lazy fetcher for a single replay item. The returned
572
+ # lambda takes an externalSpanId and returns that span's deserialized
573
+ # recorded output, fetching it via get_external_span at most once per id
574
+ # (a span read twice - as a base "marked" mock and via an override's
575
+ # get_original_output - fetches once). The cache is per item: each item
576
+ # replays synchronously on its own thread, so no lock is needed.
577
+ def build_span_output_fetcher(http_client)
578
+ cache = {}
579
+ lambda do |external_span_id|
580
+ return cache[external_span_id] if cache.key?(external_span_id)
581
+
582
+ span = http_client.get_external_span(external_span_id)
583
+ span_data = (span["rawData"] || {})["span_data"] || {}
584
+ # Prefer the Ruby Marshal payload (output_serialized) written by this
585
+ # SDK; fall back to another SDK's output_meta, then the raw JSON output.
586
+ meta = span_data["output_serialized"]
587
+ meta = span_data["output_meta"] if meta.nil? || meta == ""
588
+ cache[external_span_id] = Serialize.deserialize_output(span_data["output"], meta)
589
+ end
590
+ end
591
+
465
592
  # Extract input/output data from an external span's rawData.
466
593
  def extract_span_data(span)
467
594
  raw_data = span["rawData"] || {}
@@ -505,12 +632,17 @@ module Bitfab
505
632
 
506
633
  # Execute a single replay item: deserialize inputs, call method with replay context.
507
634
  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)
635
+ input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
636
+ fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil,
637
+ db_snapshot_ref: nil)
510
638
  args, kwargs = Serialize.deserialize_inputs(item)
511
639
 
512
640
  fn_result = nil
513
641
  fn_error = nil
642
+ # Client-side correlation id that tags this item's replay spans so the
643
+ # server can echo back the row id it minted (resolved in run()'s
644
+ # complete-replay loop). Carried on the item under :_sdk_trace_id, never
645
+ # surfaced as the public :trace_id.
514
646
  sdk_trace_id = SecureRandom.uuid
515
647
  # Collects the root span's persistence threads (span uploads + trace
516
648
  # completion). Joined below so this item's trace is on the server
@@ -525,6 +657,8 @@ module Bitfab
525
657
  trace_id: sdk_trace_id,
526
658
  mock_tree:,
527
659
  mock_strategy:,
660
+ mock_overrides:,
661
+ fetch_span_output:,
528
662
  pending_persistence:,
529
663
  db_branch_lease:,
530
664
  source_bitfab_trace_id:
@@ -533,7 +667,13 @@ module Bitfab
533
667
  # supplied. Inside the rescue so a raising adapter surfaces on this
534
668
  # item's :error instead of crashing the run; args is reported on :input.
535
669
  if adapt_inputs
536
- ctx = adapt_ctx || {trace_id: nil, source_span_id: input_source_span_id}
670
+ ctx = adapt_ctx || {
671
+ original_trace_id: nil,
672
+ original_span_id: input_source_span_id,
673
+ # Deprecated aliases for original_trace_id/original_span_id.
674
+ source_trace_id: nil,
675
+ source_span_id: input_source_span_id
676
+ }
537
677
  args, kwargs = adapt_inputs.call(args, kwargs, ctx)
538
678
  end
539
679
  fn_result = if kwargs.empty?
@@ -559,7 +699,16 @@ module Bitfab
559
699
  duration_ms: metrics[:duration_ms],
560
700
  tokens: metrics[:tokens],
561
701
  model: metrics[:model],
562
- trace_id: sdk_trace_id,
702
+ # Written in by run() from the complete-replay response once the server
703
+ # has minted this replay trace's row. Nil until then: the client-side
704
+ # correlation id (below) is never surfaced as the public :trace_id.
705
+ trace_id: nil,
706
+ _sdk_trace_id: sdk_trace_id,
707
+ original_trace_id: source_bitfab_trace_id,
708
+ original_span_id: input_source_span_id,
709
+ # Deprecated aliases for original_trace_id/original_span_id.
710
+ source_trace_id: source_bitfab_trace_id,
711
+ source_span_id: input_source_span_id,
563
712
  db_snapshot_ref:
564
713
  }
565
714
  end
@@ -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.1"
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.1
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