bitfab 0.10.6 → 0.12.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: f20353e1bb03affda1786ed5f8566ab1b3a401f0f9635023bae2fd350bcc7de6
4
- data.tar.gz: 8e10f199e9d81cfdbfa2bc9172327131301dc3959307340bbe6c55e1ef2bc235
3
+ metadata.gz: 0e4ca1b81e502de48fe20835b4d072b05736ac5f4ff1a444d683627818648675
4
+ data.tar.gz: d7120ea5b06e8da151cd9ca26c9549b017458cc2d3b1fa9078ffa512c59d8606
5
5
  SHA512:
6
- metadata.gz: ade88c2b4d5878a3f6e0cee6f70c4a2d63b1a2f0359f425e9da6ab787806c8ef9396ca0e0a71f5ec7c75647e658419f0e9eb099fbbb4ce22a56c38adf26632e8
7
- data.tar.gz: 9aa7b82e532a84893533b97d67d413cd4012729624d6bde8433bfdecf73c1d929269e6a1d779e50ed7d13e6dcc84a050f22fdf41282f2de4469bd0c5a6a35b30
6
+ metadata.gz: b2cbfda78dfd7d726174ace8910f507ea3982d68bb19c402fcaa50b84a20eeddbaf7373932fbec81c93116d8056b41eef876f132aa27913cade2937506299e37
7
+ data.tar.gz: 41fa08a3eb1a36f21af3358570012625564473eeafd847ab0073c33e012fb341c2b42463a1247e73c929679a1075220c096a4e35a2ac5c7e81151268f3cbbd3c
data/README.md CHANGED
@@ -276,6 +276,62 @@ client = ExternalHttpClient.new
276
276
  client.get("https://api.example.com")
277
277
  ```
278
278
 
279
+ ### Fluent API: `client.get_function`
280
+
281
+ Bind a `trace_function_key` once and wrap multiple methods or classes against it. Mirrors `client.get_function` in the Python SDK and `client.getFunction` in TypeScript.
282
+
283
+ ```ruby
284
+ fn = Bitfab.client.get_function("openai")
285
+
286
+ fn.wrap(OpenAI::Client, :chat, name: "Chat", type: "llm")
287
+ fn.wrap(OpenAI::Client, :embeddings, name: "Embed", type: "llm")
288
+ ```
289
+
290
+ `#wrap` accepts the same options as `Bitfab::Traceable.wrap` (`name`, `type`, `mock_on_replay`), but the `trace_function_key` is fixed to the one bound on the `BitfabFunction`.
291
+
292
+ ### Replay with Mock Strategies
293
+
294
+ Replay reruns historical traces through your code so you can compare outputs after an iteration. By default every child span runs real code — fine for offline traces, but expensive when children make paid LLM/API calls. Three strategies control whether child spans return their historical output instead of executing:
295
+
296
+ ```ruby
297
+ # "none" (default): everything runs real code
298
+ client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "none")
299
+
300
+ # "all": every child span returns its historical output
301
+ client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "all")
302
+
303
+ # "marked": only spans tagged with `mock_on_replay: true` return historical output
304
+ client.replay(pipeline, :process, trace_function_key: "my-fn", mock: "marked")
305
+ ```
306
+
307
+ Tag the spans you want mocked at definition time:
308
+
309
+ ```ruby
310
+ class Pipeline
311
+ include Bitfab::Traceable
312
+ bitfab_function "my-fn"
313
+
314
+ # mock_on_replay: true → returns historical output under mock: "marked"
315
+ bitfab_span :call_llm, type: "llm", mock_on_replay: true
316
+ def call_llm(prompt)
317
+ # paid OpenAI call — skip during replay
318
+ end
319
+
320
+ bitfab_span :transform, type: "function"
321
+ def transform(text)
322
+ # cheap, deterministic — keep running real
323
+ end
324
+
325
+ bitfab_span :process, type: "agent"
326
+ def process(text)
327
+ call_llm(text)
328
+ transform(text)
329
+ end
330
+ end
331
+ ```
332
+
333
+ Use `mock: "marked"` 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
+
279
335
  ### Error Handling
280
336
 
281
337
  Errors are automatically captured and re-raised:
data/lib/bitfab/client.rb CHANGED
@@ -13,6 +13,12 @@ module Bitfab
13
13
  class Client
14
14
  SPAN_TYPES = %w[llm agent function guardrail handoff custom].freeze
15
15
 
16
+ # Sentinel returned by check_mock_replay when this span should run real
17
+ # code (no mock active, wrong strategy, or no matching historical entry).
18
+ # Using a sentinel rather than nil/false avoids confusing legitimate mocked
19
+ # outputs (which may themselves be nil or false).
20
+ MOCK_REPLAY_MISS = Object.new.freeze
21
+
16
22
  attr_reader :api_key, :service_url, :enabled
17
23
 
18
24
  def initialize(api_key:, service_url: nil, enabled: true)
@@ -40,19 +46,41 @@ module Bitfab
40
46
  # code change being tested in this replay (stored on the experiment)
41
47
  # @param code_change_files [Array<Hash>, nil] optional list of edited files,
42
48
  # each as { path:, before:, after: } (use "" for new/deleted files)
49
+ # @param mock [String] mock strategy for child spans: "none" (default),
50
+ # "all", or "marked". "all" mocks every child span; "marked" only mocks
51
+ # spans declared with mock_on_replay: true.
43
52
  # @return [Hash] with :items, :test_run_id, :test_run_url
44
53
  def replay(receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10,
45
- code_change_description: nil, code_change_files: nil)
54
+ code_change_description: nil, code_change_files: nil, mock: "none")
46
55
  Replay.run(
47
56
  self, receiver, method_name,
48
57
  trace_function_key:, limit:, trace_ids:, max_concurrency:,
49
- code_change_description:, code_change_files:
58
+ code_change_description:, code_change_files:, mock:
50
59
  )
51
60
  end
52
61
 
62
+ # Get a function wrapper bound to a specific trace function key.
63
+ #
64
+ # This provides a fluent API for binding a trace_function_key once and
65
+ # then wrapping multiple methods or classes with that key. Mirrors
66
+ # `client.get_function(key)` in the Python SDK and `client.getFunction(key)`
67
+ # in the TypeScript SDK.
68
+ #
69
+ # @example
70
+ # fn = Bitfab.client.get_function("order-processing")
71
+ # fn.wrap(OrderService, :process_order, type: "function")
72
+ # fn.wrap(OrderService, :validate_order, type: "guardrail")
73
+ #
74
+ # @param trace_function_key [String]
75
+ # @return [BitfabFunction]
76
+ def get_function(trace_function_key)
77
+ BitfabFunction.new(self, trace_function_key)
78
+ end
79
+
53
80
  # Execute a block inside a span context, sending trace data on completion.
54
81
  # Called by Traceable — not intended for direct use.
55
- def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:)
82
+ def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
83
+ mock_on_replay: false)
56
84
  return yield unless @enabled
57
85
 
58
86
  parent = SpanContext.current
@@ -80,6 +108,37 @@ module Bitfab
80
108
  @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
81
109
  end
82
110
 
111
+ # Advance the per-(key, name) call counter for any non-root span under
112
+ # an active mock tree, even when this span won't itself be mocked.
113
+ # Unmarked spans must consume an index so subsequent marked siblings
114
+ # line up with `build_mock_tree`'s sequential numbering for the same
115
+ # (key, name) pair. Different (key, name) pairs have independent
116
+ # counters — they cannot shift each other.
117
+ call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
118
+ if call_index
119
+ mocked_output = check_mock_replay(
120
+ replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
121
+ )
122
+ if mocked_output != MOCK_REPLAY_MISS
123
+ send_mocked_span(
124
+ trace_function_key:,
125
+ trace_id:,
126
+ span_id:,
127
+ parent_span_id:,
128
+ span_name:,
129
+ span_type:,
130
+ function_name:,
131
+ args:,
132
+ kwargs:,
133
+ mocked_output:,
134
+ started_at:,
135
+ test_run_id: resolved_test_run_id,
136
+ input_source_span_id: resolved_input_source_span_id
137
+ )
138
+ return mocked_output
139
+ end
140
+ end
141
+
83
142
  result = nil
84
143
  error = nil
85
144
  span_contexts = nil
@@ -304,5 +363,137 @@ module Bitfab
304
363
 
305
364
  @http_client.send_external_span(payload) # Returns the background thread
306
365
  end
366
+
367
+ # Advance the per-(key, name) call counter when this invocation is a
368
+ # non-root span under an active mock tree. Returns the call index this
369
+ # invocation owns, or nil when there's nothing to advance (root span, or
370
+ # no replay mock context). The counter MUST advance for every child span
371
+ # sharing the same (key, name) pair — including spans that won't be
372
+ # mocked — so unmarked spans don't silently shift subsequent marked
373
+ # spans' indices. Different (key, name) pairs have independent counters.
374
+ def advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
375
+ return nil if is_root_span
376
+ return nil unless replay_ctx&.dig(:mock_tree)
377
+
378
+ counters = replay_ctx[:call_counters]
379
+ counter_key = "#{trace_function_key}:#{span_name}"
380
+ call_index = counters[counter_key] || 0
381
+ counters[counter_key] = call_index + 1
382
+ call_index
383
+ end
384
+
385
+ # Decide whether this child span should be short-circuited to its recorded
386
+ # output. Returns MOCK_REPLAY_MISS when the span should run real code,
387
+ # otherwise returns the deserialized historical output.
388
+ def check_mock_replay(replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:)
389
+ strategy = replay_ctx[:mock_strategy]
390
+ case strategy
391
+ when "marked"
392
+ return MOCK_REPLAY_MISS unless mock_on_replay
393
+ when "all"
394
+ # All non-root spans are eligible
395
+ else
396
+ return MOCK_REPLAY_MISS
397
+ end
398
+
399
+ mock_entry = replay_ctx[:mock_tree]["#{trace_function_key}:#{span_name}:#{call_index}"]
400
+ return MOCK_REPLAY_MISS unless mock_entry
401
+
402
+ output = mock_entry[:output]
403
+ output_meta = mock_entry[:output_meta]
404
+
405
+ # Type-preserving deserialization when the server included Ruby-side
406
+ # Marshal+Base64 metadata. Falls back to the JSON output silently — the
407
+ # spanTree endpoint currently returns superjson/jsonpickle-shaped meta,
408
+ # which Ruby cannot reconstruct.
409
+ if output_meta.is_a?(String) && !output_meta.empty?
410
+ begin
411
+ output = Serialize.unmarshal_value(output_meta)
412
+ rescue
413
+ # Fall through to the JSON output
414
+ end
415
+ end
416
+
417
+ output
418
+ end
419
+
420
+ # Record a span entry for a mocked invocation so the test run reflects the
421
+ # mocked execution. Mirrors send_span's payload shape but with the mocked
422
+ # output as the result and no error. The returned background thread is
423
+ # registered with @pending_span_threads so the root span's finalize joins
424
+ # it before sending trace completion; without this the trace completion
425
+ # can race ahead of the mocked span's HTTP send and the trace lands
426
+ # temporarily incomplete on the server.
427
+ def send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
428
+ span_name:, span_type:, function_name:, args:, kwargs:, mocked_output:,
429
+ started_at:, test_run_id:, input_source_span_id:)
430
+ ended_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
431
+ span_thread = send_span(
432
+ trace_function_key:,
433
+ trace_id:,
434
+ span_id:,
435
+ parent_span_id:,
436
+ span_name:,
437
+ span_type:,
438
+ function_name:,
439
+ contexts: nil,
440
+ prompt: nil,
441
+ args:,
442
+ kwargs:,
443
+ result: mocked_output,
444
+ error: nil,
445
+ started_at:,
446
+ ended_at:,
447
+ test_run_id:,
448
+ input_source_span_id:
449
+ )
450
+ # Mocked spans are always non-root (advance_mock_counter returns nil for
451
+ # root spans, so check_mock_replay never short-circuits them), so the
452
+ # thread always belongs in the parent's pending list, never standalone.
453
+ @pending_span_mutex.synchronize do
454
+ @pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
455
+ end
456
+ rescue Exception # rubocop:disable Lint/RescueException
457
+ # Never crash the host app — mocked span recording is best-effort
458
+ end
459
+ end
460
+
461
+ # Fluent wrapper bound to a single trace_function_key. Mirrors
462
+ # `BitfabFunction` in the Python SDK and `BitfabFunction` in the TypeScript
463
+ # SDK — lets callers wrap multiple methods without repeating the key.
464
+ class BitfabFunction
465
+ attr_reader :trace_function_key
466
+
467
+ def initialize(client, trace_function_key)
468
+ @client = client
469
+ @trace_function_key = trace_function_key
470
+ end
471
+
472
+ # Wrap an existing method on a class with span tracing, binding this
473
+ # function's trace_function_key.
474
+ #
475
+ # Routes spans through the client this function was created from (matches
476
+ # Python's `BitfabFunction.span()` using `self._client.span(...)` and
477
+ # TypeScript's `BitfabFunction.withSpan()` using `this.client.withSpan(...)`),
478
+ # so non-global `Bitfab::Client` instances don't silently fall back to
479
+ # `Bitfab.client`.
480
+ #
481
+ # @example
482
+ # fn = Bitfab.client.get_function("openai")
483
+ # fn.wrap(OpenAI::Client, :chat, name: "Chat", type: "llm")
484
+ #
485
+ # @param klass [Class, Module] the class to wrap
486
+ # @param method_name [Symbol] the method to wrap
487
+ # @param name [String, nil] explicit span name (defaults to method name)
488
+ # @param type [String] span type
489
+ # @param mock_on_replay [Boolean] mark this span for the "marked" mock strategy
490
+ def wrap(klass, method_name, name: nil, type: "custom", mock_on_replay: false)
491
+ Bitfab::Traceable.wrap(
492
+ klass, method_name,
493
+ trace_function_key: @trace_function_key,
494
+ name:, type:, mock_on_replay:,
495
+ client: @client
496
+ )
497
+ end
307
498
  end
308
499
  end
@@ -120,6 +120,17 @@ module Bitfab
120
120
  get("/api/sdk/externalSpans/#{span_id}", timeout: 30)
121
121
  end
122
122
 
123
+ # Fetch the span tree rooted at an external span. Blocking GET request.
124
+ # Used by replay when a mock strategy is active so child spans can be
125
+ # matched against their historical outputs.
126
+ #
127
+ # Returns a hash shaped { "root" => SpanTreeNode } where each node has
128
+ # sourceSpanId, traceFunctionKey, spanName, type, output, optional
129
+ # outputMeta, and children.
130
+ def get_span_tree(external_span_id)
131
+ get("/api/sdk/replay/spanTree/#{external_span_id}", timeout: 30)
132
+ end
133
+
123
134
  # Mark a replay test run as completed. Blocking call.
124
135
  def complete_replay(test_run_id)
125
136
  request("/api/sdk/replay/complete", {"testRunId" => test_run_id}, timeout: 30)
data/lib/bitfab/replay.rb CHANGED
@@ -4,6 +4,14 @@ require_relative "constants"
4
4
  require_relative "serialize"
5
5
 
6
6
  module Bitfab
7
+ # Replay mock strategies. Mirrors the Python and TypeScript SDKs.
8
+ #
9
+ # - "none" — every child span runs real code (default)
10
+ # - "all" — every child span returns its historical output
11
+ # - "marked" — only spans declared with mock_on_replay: true return historical
12
+ # output; everything else runs real code
13
+ MOCK_STRATEGIES = %w[none all marked].freeze
14
+
7
15
  # Thread-local replay context management.
8
16
  module ReplayContext
9
17
  module_function
@@ -14,13 +22,26 @@ module Bitfab
14
22
 
15
23
  # Execute a block with replay context set on the current thread.
16
24
  # The context is automatically cleared when the block completes.
17
- def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil)
25
+ #
26
+ # @param test_run_id [String]
27
+ # @param input_source_span_id [String, nil]
28
+ # @param input_source_trace_id [String, nil]
29
+ # @param mock_tree [Hash{String => Hash}, nil] keyed by "#{key}:#{index}"
30
+ # @param mock_strategy [String, nil] one of MOCK_STRATEGIES
31
+ def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil,
32
+ mock_tree: nil, mock_strategy: nil)
18
33
  previous = Thread.current[REPLAY_CONTEXT_KEY]
19
- Thread.current[REPLAY_CONTEXT_KEY] = {
34
+ ctx = {
20
35
  test_run_id:,
21
36
  input_source_span_id:,
22
37
  input_source_trace_id:
23
38
  }
39
+ if mock_tree
40
+ ctx[:mock_tree] = mock_tree
41
+ ctx[:mock_strategy] = mock_strategy || "none"
42
+ ctx[:call_counters] = {}
43
+ end
44
+ Thread.current[REPLAY_CONTEXT_KEY] = ctx
24
45
  yield
25
46
  ensure
26
47
  Thread.current[REPLAY_CONTEXT_KEY] = previous
@@ -47,9 +68,16 @@ module Bitfab
47
68
  # code change being tested in this replay (stored on the experiment)
48
69
  # @param code_change_files [Array<Hash>, nil] optional list of edited files,
49
70
  # each as { path:, before:, after: } (empty string for new/deleted files)
71
+ # @param mock [String] mock strategy for child spans: "none" (default),
72
+ # "all", or "marked". "all" mocks every child span; "marked" only mocks
73
+ # spans declared with mock_on_replay: true.
50
74
  # @return [Hash] with :items, :test_run_id, :test_run_url
51
75
  def run(client, receiver, method_name, trace_function_key:, limit: 5, trace_ids: nil, max_concurrency: 10,
52
- code_change_description: nil, code_change_files: nil)
76
+ code_change_description: nil, code_change_files: nil, mock: "none")
77
+ unless MOCK_STRATEGIES.include?(mock.to_s)
78
+ raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
79
+ end
80
+
53
81
  http_client = client.instance_variable_get(:@http_client)
54
82
 
55
83
  replay_data = http_client.start_replay(
@@ -64,7 +92,7 @@ module Bitfab
64
92
  server_items = replay_data["items"] || []
65
93
 
66
94
  result_items = if server_items.any?
67
- process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
95
+ process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s)
68
96
  else
69
97
  []
70
98
  end
@@ -85,11 +113,13 @@ module Bitfab
85
113
  end
86
114
 
87
115
  # Process all replay items, optionally in parallel using threads.
88
- def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency)
116
+ def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy)
89
117
  concurrency = max_concurrency || server_items.length
90
118
 
91
119
  if concurrency <= 1
92
- server_items.map { |item| process_single_item(http_client, item, receiver, method_name, test_run_id) }
120
+ server_items.map do |item|
121
+ process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy)
122
+ end
93
123
  else
94
124
  results_mutex = Mutex.new
95
125
  results = []
@@ -102,7 +132,7 @@ module Bitfab
102
132
  item, idx = work_mutex.synchronize { work_queue.shift }
103
133
  break unless item
104
134
 
105
- result = process_single_item(http_client, item, receiver, method_name, test_run_id)
135
+ result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy)
106
136
  results_mutex.synchronize { results[idx] = result }
107
137
  end
108
138
  end
@@ -114,10 +144,17 @@ module Bitfab
114
144
  end
115
145
 
116
146
  # Fetch span data and execute a single replay item.
117
- def process_single_item(http_client, server_item, receiver, method_name, test_run_id)
147
+ def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy)
118
148
  span = http_client.get_external_span(server_item["externalSpanId"])
119
149
  item_data = extract_span_data(span)
120
150
  metrics = extract_server_item_metrics(server_item)
151
+
152
+ mock_tree = nil
153
+ if mock_strategy == "all" || mock_strategy == "marked"
154
+ tree = http_client.get_span_tree(server_item["externalSpanId"])
155
+ mock_tree = build_mock_tree(tree["root"] || {})
156
+ end
157
+
121
158
  execute_item(
122
159
  item_data,
123
160
  receiver,
@@ -125,10 +162,51 @@ module Bitfab
125
162
  test_run_id,
126
163
  span["id"],
127
164
  metrics,
128
- input_source_trace_id: span["externalTraceId"]
165
+ input_source_trace_id: span["externalTraceId"],
166
+ mock_strategy:,
167
+ mock_tree:
129
168
  )
130
169
  end
131
170
 
171
+ # Walk the children of a root span tree node depth-first and build a
172
+ # lookup keyed by "#{trace_function_key}:#{span_name}:#{call_index}".
173
+ #
174
+ # The root node itself is excluded — at replay time the runtime root span
175
+ # never queries the mock tree.
176
+ #
177
+ # The compound (key, name) match disambiguates same-key spans that come
178
+ # from the fluent `client.get_function(key).wrap(...)` pattern: every
179
+ # wrapped method shares trace_function_key but differs in span_name. The
180
+ # counter is per-(key, name) pair so repeated same-name calls (including
181
+ # recursion) still order by occurrence. Mirrors the Python and TypeScript
182
+ # SDKs after HVT-2078 — keying by trace_function_key alone caused the
183
+ # wrong historical output for fluent-API span sets.
184
+ def build_mock_tree(root)
185
+ spans = {}
186
+ counters = {}
187
+
188
+ walk = lambda do |node|
189
+ key = node["traceFunctionKey"]
190
+ if key && !key.empty?
191
+ name = node["spanName"]
192
+ name = key if name.nil? || name.empty?
193
+ counter_key = "#{key}:#{name}"
194
+ index = counters[counter_key] || 0
195
+ counters[counter_key] = index + 1
196
+ spans["#{counter_key}:#{index}"] = {
197
+ source_span_id: node["sourceSpanId"],
198
+ output: node["output"],
199
+ output_meta: node["outputMeta"]
200
+ }
201
+ end
202
+ (node["children"] || []).each { |child| walk.call(child) }
203
+ end
204
+
205
+ (root["children"] || []).each { |child| walk.call(child) }
206
+
207
+ spans
208
+ end
209
+
132
210
  # Extract input/output data from an external span's rawData.
133
211
  def extract_span_data(span)
134
212
  raw_data = span["rawData"] || {}
@@ -165,13 +243,19 @@ module Bitfab
165
243
 
166
244
  # Execute a single replay item: deserialize inputs, call method with replay context.
167
245
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
168
- input_source_trace_id: nil)
246
+ input_source_trace_id: nil, mock_strategy: "none", mock_tree: nil)
169
247
  args, kwargs = Serialize.deserialize_inputs(item)
170
248
 
171
249
  fn_result = nil
172
250
  fn_error = nil
173
251
 
174
- ReplayContext.with_context(test_run_id:, input_source_span_id:, input_source_trace_id:) do
252
+ ReplayContext.with_context(
253
+ test_run_id:,
254
+ input_source_span_id:,
255
+ input_source_trace_id:,
256
+ mock_tree:,
257
+ mock_strategy:
258
+ ) do
175
259
  fn_result = if kwargs.empty?
176
260
  receiver.send(method_name, *args)
177
261
  else
@@ -38,19 +38,32 @@ module Bitfab
38
38
  # @param trace_function_key [String] the trace function key
39
39
  # @param name [String, nil] explicit span name (defaults to method name)
40
40
  # @param type [String] span type: llm, agent, function, guardrail, handoff, custom
41
- def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom")
41
+ # @param mock_on_replay [Boolean] mark this span for the "marked" mock strategy.
42
+ # When true, `client.replay(... mock: "marked")` returns this span's
43
+ # historical output instead of executing the wrapped method.
44
+ # @param client [Bitfab::Client, nil] route spans through this specific client
45
+ # instead of the global `Bitfab.client`. When nil (default), the wrapper
46
+ # resolves `Bitfab.client` at each call (so `Bitfab.configure` / `reset!`
47
+ # between calls keeps working). Used by `Bitfab::Client#get_function` to
48
+ # preserve the bound client through the fluent wrapper, matching Python's
49
+ # `BitfabFunction.span()` and TypeScript's `BitfabFunction.withSpan()`.
50
+ def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom",
51
+ mock_on_replay: false, client: nil)
42
52
  span_name = name || method_name.to_s
43
53
  method_name_str = method_name.to_s
54
+ bound_client = client
44
55
 
45
56
  wrapper = Module.new do
46
57
  define_method(method_name) do |*args, **kwargs, &block|
47
- Bitfab.client.send(:execute_span,
58
+ target_client = bound_client || Bitfab.client
59
+ target_client.send(:execute_span,
48
60
  trace_function_key:,
49
61
  span_name:,
50
62
  span_type: type,
51
63
  function_name: method_name_str,
52
64
  args:,
53
- kwargs:) do
65
+ kwargs:,
66
+ mock_on_replay:) do
54
67
  super(*args, **kwargs, &block)
55
68
  end
56
69
  end
@@ -85,7 +98,10 @@ module Bitfab
85
98
  # @param trace_function_key [String, nil] trace function key (overrides class-level bitfab_function)
86
99
  # @param name [String, nil] explicit span name (defaults to method name)
87
100
  # @param type [String] span type: llm, agent, function, guardrail, handoff, custom
88
- def bitfab_span(method_name, trace_function_key: nil, name: nil, type: "custom")
101
+ # @param mock_on_replay [Boolean] mark this span for the "marked" mock strategy.
102
+ # When true, `client.replay(... mock: "marked")` returns this span's
103
+ # historical output instead of executing the wrapped method.
104
+ def bitfab_span(method_name, trace_function_key: nil, name: nil, type: "custom", mock_on_replay: false)
89
105
  trace_function_key ||= @bitfab_function_key
90
106
  unless trace_function_key
91
107
  raise "No trace function key provided. Pass `trace_function_key:` to `bitfab_span` " \
@@ -94,14 +110,15 @@ module Bitfab
94
110
 
95
111
  # If the method already exists (inline or after-method style), wrap it immediately
96
112
  if method_defined?(method_name) || private_method_defined?(method_name)
97
- _bitfab_wrap_method(method_name, trace_function_key:, name:, type:)
113
+ _bitfab_wrap_method(method_name, trace_function_key:, name:, type:, mock_on_replay:)
98
114
  else
99
115
  # Method doesn't exist yet (before-method style) — register for method_added hook
100
116
  @_bitfab_pending_spans ||= {}
101
117
  @_bitfab_pending_spans[method_name] = {
102
118
  trace_function_key:,
103
119
  name:,
104
- type:
120
+ type:,
121
+ mock_on_replay:
105
122
  }
106
123
  end
107
124
  end
@@ -116,7 +133,7 @@ module Bitfab
116
133
  _bitfab_wrap_method(method_name, **config)
117
134
  end
118
135
 
119
- def _bitfab_wrap_method(method_name, trace_function_key:, name: nil, type: "custom")
136
+ def _bitfab_wrap_method(method_name, trace_function_key:, name: nil, type: "custom", mock_on_replay: false)
120
137
  span_name = name || method_name.to_s
121
138
  method_name_str = method_name.to_s
122
139
 
@@ -128,7 +145,8 @@ module Bitfab
128
145
  span_type: type,
129
146
  function_name: method_name_str,
130
147
  args:,
131
- kwargs:) do
148
+ kwargs:,
149
+ mock_on_replay:) do
132
150
  super(*args, **kwargs, &block)
133
151
  end
134
152
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.10.6"
4
+ VERSION = "0.12.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.6
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team