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 +4 -4
- data/lib/bitfab/client.rb +184 -28
- data/lib/bitfab/http_client.rb +11 -4
- data/lib/bitfab/mock_override.rb +74 -0
- data/lib/bitfab/replay.rb +231 -82
- 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: 1f26df032729a0c1fbad20c807e1581ec1c68bb9019e0b8f32a85237c785a6ed
|
|
4
|
+
data.tar.gz: 6acf7a7e153565cf4fb272b651f2740b4d3cb5722b67b6508552aa1b2e81d042
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
# {
|
|
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:,
|
|
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,
|
|
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),
|
|
456
|
-
# (optional), accessed: } with
|
|
457
|
-
# the branch URL and false if it
|
|
458
|
-
# lease was attached, in which
|
|
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[:
|
|
487
|
-
usage["
|
|
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
|
|
621
|
-
#
|
|
622
|
-
#
|
|
623
|
-
|
|
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
|
-
|
|
638
|
-
|
|
777
|
+
resolve_recorded_output(mock_entry, replay_ctx)
|
|
778
|
+
end
|
|
639
779
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
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
|
|
@@ -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
|
-
# {
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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:,
|
|
110
|
-
# the single item that just
|
|
111
|
-
#
|
|
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
|
|
182
|
-
#
|
|
183
|
-
#
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 <<
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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})
|
|
241
|
-
"and
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
|
|
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:
|
|
299
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 #{
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
509
|
-
|
|
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 || {
|
|
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
|
-
|
|
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
|
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.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
|