bitfab 0.14.0 → 0.16.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 +25 -4
- data/lib/bitfab/replay.rb +104 -19
- data/lib/bitfab/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3320e43b6f1be704f5ab955161b347d954691c6cdc75f5e57ab01452e8f8dd7f
|
|
4
|
+
data.tar.gz: 2b8872ef106d63560d6718ab4d2076f6271417fbc56e44581c2e4f5058ff4a00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9599b68cfbac6812070bfd05516b1372e607e5cc2214a5e7319ca39b5e22c80390db4b3446e6c136bea2c2a0780e8ffeab5fbb99226e2bf7636ace35a9a72f5
|
|
7
|
+
data.tar.gz: 98c9855c1d5f3cb4403926820b223733c555696211ab27d0980c06f9f38e01aa02b0e4c2065525c33eed6bd8e60f0136b3a4951a67416051181287b5f7acbc7a
|
data/lib/bitfab/client.rb
CHANGED
|
@@ -53,13 +53,18 @@ module Bitfab
|
|
|
53
53
|
# @param mock [String] mock strategy for child spans: "none" (default),
|
|
54
54
|
# "all", or "marked". "all" mocks every child span; "marked" only mocks
|
|
55
55
|
# spans declared with mock_on_replay: true.
|
|
56
|
+
# @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
|
|
57
|
+
# onto the method's current signature when its shape changed after the
|
|
58
|
+
# traces were captured. Receives (args, kwargs, ctx) where ctx is
|
|
59
|
+
# { trace_id:, source_span_id: }, and returns [new_args, new_kwargs].
|
|
56
60
|
# @return [Hash] with :items, :test_run_id, :test_run_url
|
|
57
61
|
def replay(receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
|
|
58
|
-
code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none"
|
|
62
|
+
code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none",
|
|
63
|
+
adapt_inputs: nil)
|
|
59
64
|
Replay.run(
|
|
60
65
|
self, receiver, method_name,
|
|
61
66
|
trace_function_key:, limit:, trace_ids:, max_concurrency:,
|
|
62
|
-
code_change_description:, code_change_files:, experiment_group_id:, mock:
|
|
67
|
+
code_change_description:, code_change_files:, experiment_group_id:, mock:, adapt_inputs:
|
|
63
68
|
)
|
|
64
69
|
end
|
|
65
70
|
|
|
@@ -183,12 +188,24 @@ module Bitfab
|
|
|
183
188
|
pending << span_thread if span_thread
|
|
184
189
|
pending.each { |t| t.join(5) }
|
|
185
190
|
|
|
186
|
-
send_trace_completion(
|
|
191
|
+
completion_thread = send_trace_completion(
|
|
187
192
|
trace_function_key:,
|
|
188
193
|
trace_id:,
|
|
189
194
|
started_at:,
|
|
190
195
|
ended_at:
|
|
191
196
|
)
|
|
197
|
+
|
|
198
|
+
# In replay, persistence is correctness: the replay runner joins
|
|
199
|
+
# these threads before calling complete_replay, or the server's
|
|
200
|
+
# trace-ID mapping races the uploads and every item's trace_id
|
|
201
|
+
# comes back nil. The 5s join above is best-effort only; this
|
|
202
|
+
# hands the full set (span uploads + trace completion) to the
|
|
203
|
+
# runner. No-op outside replay, where sends stay fire-and-forget.
|
|
204
|
+
persistence = ReplayContext.current&.dig(:pending_persistence)
|
|
205
|
+
if persistence
|
|
206
|
+
persistence.concat(pending)
|
|
207
|
+
persistence << completion_thread if completion_thread
|
|
208
|
+
end
|
|
192
209
|
else
|
|
193
210
|
@pending_span_mutex.synchronize do
|
|
194
211
|
@pending_span_threads[trace_id] << span_thread if span_thread && @pending_span_threads.key?(trace_id)
|
|
@@ -314,10 +331,14 @@ module Bitfab
|
|
|
314
331
|
payload["testRunId"] = trace_state[:test_run_id]
|
|
315
332
|
end
|
|
316
333
|
|
|
317
|
-
@http_client.send_external_trace(payload)
|
|
334
|
+
completion_thread = @http_client.send_external_trace(payload)
|
|
318
335
|
|
|
319
336
|
# Clean up trace state
|
|
320
337
|
TraceState.delete(trace_id)
|
|
338
|
+
|
|
339
|
+
# Returned so the replay path can join it — trace completions must be
|
|
340
|
+
# persisted before complete_replay builds the trace-ID mapping.
|
|
341
|
+
completion_thread
|
|
321
342
|
end
|
|
322
343
|
|
|
323
344
|
def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
|
data/lib/bitfab/replay.rb
CHANGED
|
@@ -22,8 +22,12 @@ module Bitfab
|
|
|
22
22
|
|
|
23
23
|
# Execute a block with replay context set on the current thread.
|
|
24
24
|
# The context is automatically cleared when the block completes.
|
|
25
|
+
#
|
|
26
|
+
# pending_persistence, when given, collects the root span's persistence
|
|
27
|
+
# threads (span uploads + trace completion) so the replay runner can join
|
|
28
|
+
# them before complete_replay builds the trace-ID mapping.
|
|
25
29
|
def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
|
|
26
|
-
mock_tree: nil, mock_strategy: nil)
|
|
30
|
+
mock_tree: nil, mock_strategy: nil, pending_persistence: nil)
|
|
27
31
|
previous = Thread.current[REPLAY_CONTEXT_KEY]
|
|
28
32
|
ctx = {
|
|
29
33
|
test_run_id:,
|
|
@@ -31,6 +35,7 @@ module Bitfab
|
|
|
31
35
|
input_source_trace_id:,
|
|
32
36
|
trace_id:
|
|
33
37
|
}
|
|
38
|
+
ctx[:pending_persistence] = pending_persistence if pending_persistence
|
|
34
39
|
if mock_tree
|
|
35
40
|
ctx[:mock_tree] = mock_tree
|
|
36
41
|
ctx[:mock_strategy] = mock_strategy || "none"
|
|
@@ -70,9 +75,16 @@ module Bitfab
|
|
|
70
75
|
# @param mock [String] mock strategy for child spans: "none" (default),
|
|
71
76
|
# "all", or "marked". "all" mocks every child span; "marked" only mocks
|
|
72
77
|
# spans declared with mock_on_replay: true.
|
|
78
|
+
# @param adapt_inputs [#call, nil] optional hook to reshape recorded inputs
|
|
79
|
+
# onto the method's current signature when its shape changed after the
|
|
80
|
+
# traces were captured. Receives (args, kwargs, ctx) where ctx is
|
|
81
|
+
# { trace_id:, source_span_id: }, and returns [new_args, new_kwargs]. Runs
|
|
82
|
+
# per item inside the same rescue as the method, so a raising adapter sets
|
|
83
|
+
# that item's :error rather than crashing the run.
|
|
73
84
|
# @return [Hash] with :items, :test_run_id, :test_run_url
|
|
74
85
|
def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, max_concurrency: 10,
|
|
75
|
-
code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none"
|
|
86
|
+
code_change_description: nil, code_change_files: nil, experiment_group_id: nil, mock: "none",
|
|
87
|
+
adapt_inputs: nil)
|
|
76
88
|
unless MOCK_STRATEGIES.include?(mock.to_s)
|
|
77
89
|
raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}"
|
|
78
90
|
end
|
|
@@ -106,22 +118,69 @@ module Bitfab
|
|
|
106
118
|
server_items = replay_data["items"] || []
|
|
107
119
|
|
|
108
120
|
result_items = if server_items.any?
|
|
109
|
-
process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s
|
|
121
|
+
process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s,
|
|
122
|
+
adapt_inputs)
|
|
110
123
|
else
|
|
111
124
|
[]
|
|
112
125
|
end
|
|
113
126
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
127
|
+
# Every item joined its own trace-persistence threads (span uploads +
|
|
128
|
+
# completion) in execute_item, so all replay traces are on the server
|
|
129
|
+
# by now — no flush needed, and complete_replay's trace-ID mapping is
|
|
130
|
+
# deterministic. complete_replay failures propagate: a missing mapping
|
|
131
|
+
# means verdicts can't be persisted, which callers must hear about
|
|
132
|
+
# loudly.
|
|
133
|
+
complete_response = http_client.complete_replay(test_run_id)
|
|
134
|
+
trace_id_map = complete_response&.dig("traceIds")
|
|
135
|
+
|
|
136
|
+
if trace_id_map.nil?
|
|
137
|
+
# Older servers don't return the mapping. Preserve the legacy
|
|
138
|
+
# nil-trace_id behavior but say why.
|
|
139
|
+
warn "Bitfab: server did not return replay trace IDs; item trace_id " \
|
|
140
|
+
"will be nil (server upgrade required for verdict persistence)"
|
|
141
|
+
result_items.each { |item| item[:trace_id] = nil }
|
|
142
|
+
else
|
|
143
|
+
# Map each item's locally-generated trace ID to the server's trace
|
|
144
|
+
# row ID. A completed item with no mapping means its trace was sent
|
|
145
|
+
# but the server has no record — a nil trace_id blocks verdict
|
|
146
|
+
# persistence and the Studio experiments view downstream, so this
|
|
147
|
+
# must never be silent.
|
|
148
|
+
#
|
|
149
|
+
# Severity splits on scope:
|
|
150
|
+
# - ALL completed items missing: systemic (the replayed method is
|
|
151
|
+
# not traced, or uploads are wholesale broken). Raise; the run's
|
|
152
|
+
# results are unusable for persistence.
|
|
153
|
+
# - SOME completed items missing: per-item upload failure (transient
|
|
154
|
+
# network blip, one oversized payload). Nil those items and warn
|
|
155
|
+
# loudly, but return the run so callers can persist verdicts for
|
|
156
|
+
# the items that landed.
|
|
157
|
+
missing = []
|
|
158
|
+
completed_count = 0
|
|
119
159
|
result_items.each do |item|
|
|
120
|
-
|
|
160
|
+
next unless item[:trace_id]
|
|
161
|
+
|
|
162
|
+
mapped = trace_id_map[item[:trace_id]]
|
|
163
|
+
if item[:error].nil?
|
|
164
|
+
completed_count += 1
|
|
165
|
+
missing << item[:trace_id] if mapped.nil?
|
|
166
|
+
end
|
|
167
|
+
item[:trace_id] = mapped
|
|
168
|
+
end
|
|
169
|
+
if missing.any?
|
|
170
|
+
trace_count = complete_response["traceCount"]
|
|
171
|
+
server_count = trace_count.nil? ? "" : " The server persisted #{trace_count} trace(s) for this run."
|
|
172
|
+
if missing.length == completed_count
|
|
173
|
+
raise "Replay completed but the server has no persisted trace for " \
|
|
174
|
+
"any of the #{completed_count} completed item(s) " \
|
|
175
|
+
"(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \
|
|
176
|
+
"joined, so either the uploads failed or the replayed method is " \
|
|
177
|
+
"not traced (no root span was emitted)."
|
|
178
|
+
end
|
|
179
|
+
warn "Bitfab: server has no persisted trace for #{missing.length} of " \
|
|
180
|
+
"#{completed_count} completed replay item(s) " \
|
|
181
|
+
"(test_run_id #{test_run_id}).#{server_count} Their trace_id is nil " \
|
|
182
|
+
"and verdicts cannot be persisted for them. Missing: #{missing.join(", ")}"
|
|
121
183
|
end
|
|
122
|
-
rescue => e
|
|
123
|
-
warn "Bitfab: Failed to complete replay: #{e.message}"
|
|
124
|
-
result_items.each { |item| item[:trace_id] = nil }
|
|
125
184
|
end
|
|
126
185
|
|
|
127
186
|
{
|
|
@@ -132,12 +191,13 @@ module Bitfab
|
|
|
132
191
|
end
|
|
133
192
|
|
|
134
193
|
# Process all replay items, optionally in parallel using threads.
|
|
135
|
-
def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy
|
|
194
|
+
def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy,
|
|
195
|
+
adapt_inputs = nil)
|
|
136
196
|
concurrency = max_concurrency || server_items.length
|
|
137
197
|
|
|
138
198
|
if concurrency <= 1
|
|
139
199
|
server_items.map do |item|
|
|
140
|
-
process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy)
|
|
200
|
+
process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs)
|
|
141
201
|
end
|
|
142
202
|
else
|
|
143
203
|
results_mutex = Mutex.new
|
|
@@ -151,7 +211,8 @@ module Bitfab
|
|
|
151
211
|
item, idx = work_mutex.synchronize { work_queue.shift }
|
|
152
212
|
break unless item
|
|
153
213
|
|
|
154
|
-
result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy
|
|
214
|
+
result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy,
|
|
215
|
+
adapt_inputs)
|
|
155
216
|
results_mutex.synchronize { results[idx] = result }
|
|
156
217
|
end
|
|
157
218
|
end
|
|
@@ -168,7 +229,8 @@ module Bitfab
|
|
|
168
229
|
# deserializing inputs is captured on the returned item's :error rather
|
|
169
230
|
# than propagated, so one bad trace never aborts the whole replay run
|
|
170
231
|
# (mirrors the TypeScript and Python SDKs' per-item rescue).
|
|
171
|
-
def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy
|
|
232
|
+
def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy,
|
|
233
|
+
adapt_inputs = nil)
|
|
172
234
|
metrics = extract_server_item_metrics(server_item)
|
|
173
235
|
|
|
174
236
|
span = http_client.get_external_span(server_item["externalSpanId"])
|
|
@@ -180,6 +242,8 @@ module Bitfab
|
|
|
180
242
|
mock_tree = build_mock_tree(tree["root"] || {})
|
|
181
243
|
end
|
|
182
244
|
|
|
245
|
+
adapt_ctx = {trace_id: server_item["traceId"], source_span_id: server_item["externalSpanId"]}
|
|
246
|
+
|
|
183
247
|
execute_item(
|
|
184
248
|
item_data,
|
|
185
249
|
receiver,
|
|
@@ -189,7 +253,9 @@ module Bitfab
|
|
|
189
253
|
metrics,
|
|
190
254
|
input_source_trace_id: span["externalTraceId"],
|
|
191
255
|
mock_strategy:,
|
|
192
|
-
mock_tree
|
|
256
|
+
mock_tree:,
|
|
257
|
+
adapt_inputs:,
|
|
258
|
+
adapt_ctx:
|
|
193
259
|
)
|
|
194
260
|
rescue => e
|
|
195
261
|
warn "Bitfab: replay item for span #{server_item["externalSpanId"]} failed before execution: #{e.message}"
|
|
@@ -280,12 +346,17 @@ module Bitfab
|
|
|
280
346
|
|
|
281
347
|
# Execute a single replay item: deserialize inputs, call method with replay context.
|
|
282
348
|
def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
|
|
283
|
-
input_source_trace_id: nil, mock_strategy: "none", mock_tree: nil)
|
|
349
|
+
input_source_trace_id: nil, mock_strategy: "none", mock_tree: nil, adapt_inputs: nil, adapt_ctx: nil)
|
|
284
350
|
args, kwargs = Serialize.deserialize_inputs(item)
|
|
285
351
|
|
|
286
352
|
fn_result = nil
|
|
287
353
|
fn_error = nil
|
|
288
354
|
sdk_trace_id = SecureRandom.uuid
|
|
355
|
+
# Collects the root span's persistence threads (span uploads + trace
|
|
356
|
+
# completion). Joined below so this item's trace is on the server
|
|
357
|
+
# before run() calls complete_replay — otherwise the server's trace-ID
|
|
358
|
+
# mapping races the uploads and the item's trace_id comes back nil.
|
|
359
|
+
pending_persistence = []
|
|
289
360
|
|
|
290
361
|
ReplayContext.with_context(
|
|
291
362
|
test_run_id:,
|
|
@@ -293,8 +364,16 @@ module Bitfab
|
|
|
293
364
|
input_source_trace_id:,
|
|
294
365
|
trace_id: sdk_trace_id,
|
|
295
366
|
mock_tree:,
|
|
296
|
-
mock_strategy
|
|
367
|
+
mock_strategy:,
|
|
368
|
+
pending_persistence:
|
|
297
369
|
) do
|
|
370
|
+
# Reshape recorded inputs onto the current signature when an adapter is
|
|
371
|
+
# supplied. Inside the rescue so a raising adapter surfaces on this
|
|
372
|
+
# item's :error instead of crashing the run; args is reported on :input.
|
|
373
|
+
if adapt_inputs
|
|
374
|
+
ctx = adapt_ctx || {trace_id: nil, source_span_id: input_source_span_id}
|
|
375
|
+
args, kwargs = adapt_inputs.call(args, kwargs, ctx)
|
|
376
|
+
end
|
|
298
377
|
fn_result = if kwargs.empty?
|
|
299
378
|
receiver.send(method_name, *args)
|
|
300
379
|
else
|
|
@@ -304,6 +383,12 @@ module Bitfab
|
|
|
304
383
|
fn_error = e.message
|
|
305
384
|
end
|
|
306
385
|
|
|
386
|
+
# Wait for this item's trace (spans + completion) to be fully persisted
|
|
387
|
+
# before the item resolves. Runs on the error path too — a raising
|
|
388
|
+
# method still emits a root span whose trace must land before
|
|
389
|
+
# complete_replay. Joins are bounded by the HTTP layer's own timeouts.
|
|
390
|
+
pending_persistence.each(&:join)
|
|
391
|
+
|
|
307
392
|
{
|
|
308
393
|
input: args,
|
|
309
394
|
result: fn_result,
|
data/lib/bitfab/version.rb
CHANGED