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 +4 -4
- data/lib/bitfab/client.rb +169 -21
- data/lib/bitfab/http_client.rb +11 -4
- data/lib/bitfab/mock_override.rb +74 -0
- data/lib/bitfab/replay.rb +90 -16
- data/lib/bitfab/serialize.rb +21 -0
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61a89926a13fa008e693f825bb810dea734a1b17bed7ab9b9821daec3e36830f
|
|
4
|
+
data.tar.gz: 5026a5ee5252b0a9cf7a8653287e79ac626fa144bd160e7ecf138131dd8c5dba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:,
|
|
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,
|
|
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
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
|
|
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
|
-
|
|
638
|
-
|
|
769
|
+
resolve_recorded_output(mock_entry, replay_ctx)
|
|
770
|
+
end
|
|
639
771
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -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,
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
509
|
-
|
|
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:
|
data/lib/bitfab/serialize.rb
CHANGED
|
@@ -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,
|
data/lib/bitfab/version.rb
CHANGED
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.
|
|
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
|