bitfab 0.61.0 → 0.61.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 +33 -7
- data/lib/bitfab/http_client.rb +2 -1
- data/lib/bitfab/replay.rb +23 -9
- data/lib/bitfab/selective_replay.rb +235 -0
- data/lib/bitfab/simulation_plan.rb +1 -1
- data/lib/bitfab/subtree.rb +6 -1
- data/lib/bitfab/traceable.rb +11 -9
- data/lib/bitfab/version.rb +1 -1
- 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: 49768c67671b66ea8066cfa078dbba13ac3548b83b9a0abaa628ab864a61b99f
|
|
4
|
+
data.tar.gz: 76e711a41258fa421a69cd6a0475a793698f3bdd001f60d0104ec47a5096e8da
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bdef7ec071b9740bd8183056b68b84e9ab2c8004f0918678c604e9e35d6426144891bd5df0e476a59f8198e1f17459a4e0e48c08e3eab78061b119146e38b3f1
|
|
7
|
+
data.tar.gz: d441d62917aaa84b3dfbc88f6544e1630645fc5a595a7a3cf9b3e111bdbf62b43059b971ecb9f51001d45c8f28ecf7322afc07537307231fab71736f3852c80a
|
data/lib/bitfab/client.rb
CHANGED
|
@@ -192,7 +192,7 @@ module Bitfab
|
|
|
192
192
|
def replay(receiver, method_name = nil, trace_function_key:, limit: nil, trace_ids: nil, concurrency: nil, max_concurrency: ReplayConcurrency::UNSET,
|
|
193
193
|
name: nil, code_change_description: Replay::CODE_CHANGE_UNSET, code_change_files: Replay::CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
|
|
194
194
|
adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
|
|
195
|
-
on_experiment_start: nil)
|
|
195
|
+
on_experiment_start: nil, experimental_selective_replay: nil)
|
|
196
196
|
Replay.run(
|
|
197
197
|
self,
|
|
198
198
|
receiver,
|
|
@@ -220,7 +220,8 @@ module Bitfab
|
|
|
220
220
|
on_item_start:,
|
|
221
221
|
on_item_finish:,
|
|
222
222
|
on_progress:,
|
|
223
|
-
on_experiment_start
|
|
223
|
+
on_experiment_start:,
|
|
224
|
+
experimental_selective_replay:
|
|
224
225
|
)
|
|
225
226
|
end
|
|
226
227
|
|
|
@@ -337,7 +338,7 @@ module Bitfab
|
|
|
337
338
|
# Configure an existing method only when it is discovered beneath a
|
|
338
339
|
# bitfab_trace root owned by this client.
|
|
339
340
|
def node(klass, method_name, name: nil, type: "custom", capture: true,
|
|
340
|
-
experiment_id: nil, mock_on_replay: nil, finalize: nil, test_run_id: nil)
|
|
341
|
+
experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, test_run_id: nil)
|
|
341
342
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
342
343
|
Bitfab::Traceable.node(
|
|
343
344
|
klass,
|
|
@@ -347,6 +348,7 @@ module Bitfab
|
|
|
347
348
|
capture:,
|
|
348
349
|
experiment_id:,
|
|
349
350
|
mock_on_replay:,
|
|
351
|
+
replay_reusable:,
|
|
350
352
|
finalize:,
|
|
351
353
|
client: self
|
|
352
354
|
)
|
|
@@ -383,12 +385,14 @@ module Bitfab
|
|
|
383
385
|
# Execute a block inside a span context, sending trace data on completion.
|
|
384
386
|
# Called by Traceable, not intended for direct use.
|
|
385
387
|
def execute_span(trace_function_key:, span_name:, span_type:, function_name:, args:, kwargs:,
|
|
386
|
-
capture_when: "always", mock_on_replay: false, independent_root: false,
|
|
388
|
+
capture_when: "always", mock_on_replay: false, replay_reusable: false, independent_root: false,
|
|
387
389
|
explicit_span_receiver: nil, explicit_span_method_name: nil, experiment_id: nil, finalize: nil,
|
|
388
390
|
surface: nil, on_start: nil, on_complete: nil, around_body: nil, span_data: {}, managed_root: false,
|
|
389
|
-
result_recorded_elsewhere: false, declared_node: false)
|
|
391
|
+
result_recorded_elsewhere: false, declared_node: false, input_block: false)
|
|
390
392
|
return yield unless should_record?
|
|
391
393
|
|
|
394
|
+
selective_input = !input_block && (replay_reusable || mock_on_replay) && SelectiveReplay.inputs(args, kwargs)
|
|
395
|
+
|
|
392
396
|
@http_client.refresh_simulation_plan
|
|
393
397
|
|
|
394
398
|
resolved_capture_when = capture_when.to_s
|
|
@@ -465,7 +469,20 @@ module Bitfab
|
|
|
465
469
|
# line up with `build_mock_tree`'s sequential numbering for the same
|
|
466
470
|
# (key, name) pair. Different (key, name) pairs have independent
|
|
467
471
|
# counters: they cannot shift each other.
|
|
468
|
-
|
|
472
|
+
selective = replay_ctx&.dig(:selective_replay)
|
|
473
|
+
if selective
|
|
474
|
+
mocked, output = selective.enter(trace_function_key:, span_name:, span_id:, parent_span_id:,
|
|
475
|
+
args:, kwargs:, safety_mock: mock_on_replay, reuse_allowed: replay_reusable, can_reuse_output: finalize.nil? && !input_block)
|
|
476
|
+
if mocked
|
|
477
|
+
send_mocked_span(trace_function_key:, trace_id:, span_id:, parent_span_id:, span_name:, span_type:,
|
|
478
|
+
function_name:, args:, kwargs:, mocked_output: output, started_at:,
|
|
479
|
+
experiment_id: resolved_experiment_id, input_source_span_id: resolved_input_source_span_id,
|
|
480
|
+
mock_source: "recorded", instrumentation: (surface == :trace) ? "trace" : "span", declared_node:)
|
|
481
|
+
on_complete&.call(result: output, error: nil, ended_at: Bitfab.now_iso_timestamp)
|
|
482
|
+
return output
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
call_index = selective ? nil : advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
|
|
469
486
|
if call_index
|
|
470
487
|
mock_result = check_mock_replay(
|
|
471
488
|
replay_ctx,
|
|
@@ -561,6 +578,14 @@ module Bitfab
|
|
|
561
578
|
|
|
562
579
|
on_complete&.call(result: recorded_result, error: recorded_error, ended_at:)
|
|
563
580
|
|
|
581
|
+
span_data = span_data.merge("replay_reusable" => replay_reusable)
|
|
582
|
+
output_json = finalize.nil? && !result.is_a?(Enumerator) && SelectiveReplay.fingerprint(final_result)
|
|
583
|
+
if selective_input && output_json && recorded_error.nil? && !content_off
|
|
584
|
+
recorded_args, recorded_kwargs = JSON.parse(selective_input)
|
|
585
|
+
span_data = span_data.merge("replay_json_safe" => true,
|
|
586
|
+
"replay_recording" => {"input" => recorded_args, "kwargs" => recorded_kwargs, "output" => JSON.parse(output_json)})
|
|
587
|
+
end
|
|
588
|
+
|
|
564
589
|
send_span(
|
|
565
590
|
span_data: content_off ? span_data.merge("content_off_by_simulation_plan" => true) : span_data,
|
|
566
591
|
instrumentation: recorded_instrumentation,
|
|
@@ -1246,7 +1271,7 @@ module Bitfab
|
|
|
1246
1271
|
# values warn once and default to "always"
|
|
1247
1272
|
# @param mock_on_replay [Boolean] mark this span for the default "marked"
|
|
1248
1273
|
# mock strategy. A missing selected occurrence fails closed.
|
|
1249
|
-
def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, test_run_id: nil)
|
|
1274
|
+
def wrap(klass, method_name, name: nil, type: "custom", capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, test_run_id: nil)
|
|
1250
1275
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
1251
1276
|
Bitfab::Traceable.wrap(
|
|
1252
1277
|
klass,
|
|
@@ -1256,6 +1281,7 @@ module Bitfab
|
|
|
1256
1281
|
type:,
|
|
1257
1282
|
capture_when:,
|
|
1258
1283
|
mock_on_replay:,
|
|
1284
|
+
replay_reusable:,
|
|
1259
1285
|
finalize:,
|
|
1260
1286
|
experiment_id:,
|
|
1261
1287
|
client: @client
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -262,7 +262,7 @@ module Bitfab
|
|
|
262
262
|
# same org and trace function or the server rejects the replay
|
|
263
263
|
def start_replay(trace_function_key, limit, trace_ids: nil, code_change_description: nil,
|
|
264
264
|
code_change_files: nil, experiment_group_id: nil, name: nil, include_db_branch_lease: false, dataset_ids: nil,
|
|
265
|
-
grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, judge_assertions: false, include_original_metadata: false)
|
|
265
|
+
grader_ids: nil, db_branch_settings: nil, attempts: 1, only_with_assertions: false, judge_assertions: false, include_original_metadata: false, experimental_selective_replay: nil)
|
|
266
266
|
payload = {
|
|
267
267
|
"traceFunctionKey" => trace_function_key
|
|
268
268
|
}
|
|
@@ -288,6 +288,7 @@ module Bitfab
|
|
|
288
288
|
payload["onlyWithAssertions"] = true if only_with_assertions
|
|
289
289
|
payload["judgeAssertions"] = true if judge_assertions
|
|
290
290
|
payload["includeOriginalMetadata"] = true if include_original_metadata
|
|
291
|
+
payload["experimentalSelectiveReplay"] = experimental_selective_replay if experimental_selective_replay
|
|
291
292
|
payload["dbBranchSettings"] = db_branch_settings unless db_branch_settings.nil?
|
|
292
293
|
git_state = GitState.resolved
|
|
293
294
|
payload["git"] = git_state if git_state
|
data/lib/bitfab/replay.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "constants"
|
|
|
9
9
|
require_relative "git_command"
|
|
10
10
|
require_relative "mock_override"
|
|
11
11
|
require_relative "serialize"
|
|
12
|
+
require_relative "selective_replay"
|
|
12
13
|
require_relative "traceable"
|
|
13
14
|
require_relative "transport"
|
|
14
15
|
|
|
@@ -85,10 +86,11 @@ module Bitfab
|
|
|
85
86
|
# thread. Child threads and processes intentionally do not inherit it.
|
|
86
87
|
def with_context(experiment_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
|
|
87
88
|
mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
|
|
88
|
-
db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil, replay_attempt: nil)
|
|
89
|
+
db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil, replay_attempt: nil, selective_replay: nil)
|
|
89
90
|
previous = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
|
|
90
91
|
ctx = {
|
|
91
92
|
replay_attempt:,
|
|
93
|
+
selective_replay:,
|
|
92
94
|
experiment_id:,
|
|
93
95
|
input_source_span_id:,
|
|
94
96
|
input_source_trace_id:,
|
|
@@ -276,7 +278,7 @@ module Bitfab
|
|
|
276
278
|
concurrency: nil, max_concurrency: ReplayConcurrency::UNSET, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil,
|
|
277
279
|
dataset_id: nil, dataset_ids: nil, grader_ids: nil, mock: "marked", attempts: ReplayConcurrency::UNSET, only_with_assertions: false, judge_assertions: false, dry_run: false,
|
|
278
280
|
adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil,
|
|
279
|
-
on_experiment_start: nil)
|
|
281
|
+
on_experiment_start: nil, experimental_selective_replay: nil)
|
|
280
282
|
concurrency_options = ReplayConcurrency.resolve(concurrency, attempts, max_concurrency)
|
|
281
283
|
attempts = concurrency_options.attempts
|
|
282
284
|
max_concurrency = concurrency_options.max_concurrency
|
|
@@ -337,6 +339,12 @@ module Bitfab
|
|
|
337
339
|
# both take precedence over the base mock strategy.
|
|
338
340
|
registered_overrides = client.instance_variable_get(:@mock_overrides) || []
|
|
339
341
|
resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides
|
|
342
|
+
if experimental_selective_replay
|
|
343
|
+
SelectiveReplay.wire_options(experimental_selective_replay)
|
|
344
|
+
unless mock.to_s == "marked" && resolved_overrides.empty? && concurrency_options.primitive != "process"
|
|
345
|
+
raise ArgumentError, "experimental_selective_replay requires marked mocks, no overrides, and in-process replay"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
340
348
|
|
|
341
349
|
# limit is meaningless with explicit trace_ids (the ID list determines
|
|
342
350
|
# the count), so it's omitted from the request entirely.
|
|
@@ -373,7 +381,8 @@ module Bitfab
|
|
|
373
381
|
only_with_assertions:,
|
|
374
382
|
judge_assertions:,
|
|
375
383
|
include_original_metadata: !adapt_inputs.nil?,
|
|
376
|
-
db_branch_settings: resolved_db_branch_settings
|
|
384
|
+
db_branch_settings: resolved_db_branch_settings,
|
|
385
|
+
**(experimental_selective_replay ? {experimental_selective_replay: SelectiveReplay.wire_options(experimental_selective_replay)} : {})
|
|
377
386
|
)
|
|
378
387
|
experiment_id = replay_data["experimentId"] || replay_data["testRunId"]
|
|
379
388
|
experiment_url = replay_data["experimentUrl"] || replay_data["testRunUrl"]
|
|
@@ -400,7 +409,7 @@ module Bitfab
|
|
|
400
409
|
process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock.to_s,
|
|
401
410
|
adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:,
|
|
402
411
|
on_item_finish: item_finish_callback,
|
|
403
|
-
mock_overrides: resolved_overrides, dry_run:, process_executor:)
|
|
412
|
+
mock_overrides: resolved_overrides, dry_run:, process_executor:, experimental_selective_replay:)
|
|
404
413
|
else
|
|
405
414
|
[]
|
|
406
415
|
end
|
|
@@ -776,7 +785,7 @@ module Bitfab
|
|
|
776
785
|
# Process all replay items, optionally in parallel using threads.
|
|
777
786
|
def process_items(http_client, server_items, receiver, method_name, experiment_id, max_concurrency, mock_strategy,
|
|
778
787
|
adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil,
|
|
779
|
-
mock_overrides: [], dry_run: false, process_executor: nil)
|
|
788
|
+
mock_overrides: [], dry_run: false, process_executor: nil, experimental_selective_replay: nil)
|
|
780
789
|
concurrency = max_concurrency || server_items.length
|
|
781
790
|
|
|
782
791
|
# Lifecycle callbacks run from worker threads in the parallel path, so the
|
|
@@ -887,7 +896,7 @@ module Bitfab
|
|
|
887
896
|
process_executor.call(item, experiment_id, mock_strategy, include_db_branch_lease, db_branch_settings, dry_run:)
|
|
888
897
|
else
|
|
889
898
|
process_single_item(http_client, item, receiver, method_name, experiment_id, mock_strategy,
|
|
890
|
-
adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:)
|
|
899
|
+
adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:, dry_run:, experimental_selective_replay:)
|
|
891
900
|
end
|
|
892
901
|
end
|
|
893
902
|
|
|
@@ -930,7 +939,7 @@ module Bitfab
|
|
|
930
939
|
# than propagated, so one bad trace never aborts the whole replay run
|
|
931
940
|
# (mirrors the TypeScript and Python SDKs' per-item rescue).
|
|
932
941
|
def process_single_item(http_client, server_item, receiver, method_name, experiment_id, mock_strategy,
|
|
933
|
-
adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false)
|
|
942
|
+
adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: [], dry_run: false, experimental_selective_replay: nil)
|
|
934
943
|
metrics = extract_server_item_metrics(server_item)
|
|
935
944
|
attempt = server_item["attempt"] || 0
|
|
936
945
|
# The ORIGINAL (historical) trace/span this item replays. Canonical
|
|
@@ -945,6 +954,7 @@ module Bitfab
|
|
|
945
954
|
# snapshot ref arrive without a lease, so the app uses its normal DB
|
|
946
955
|
# path. Unsafe calls on that path still require replay mocking.
|
|
947
956
|
lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil
|
|
957
|
+
selective_replay = experimental_selective_replay && SelectiveReplay.new(server_item["selectiveReplayPlan"], experimental_selective_replay)
|
|
948
958
|
# A resolve that was ATTEMPTED and failed is different: the caller asked
|
|
949
959
|
# for a branch, so running their method against live data would produce a
|
|
950
960
|
# result that looks valid and is not. Fail the item instead, loudly.
|
|
@@ -994,7 +1004,7 @@ module Bitfab
|
|
|
994
1004
|
overrides_present = !mock_overrides.nil? && !mock_overrides.empty?
|
|
995
1005
|
include_outputs = mock_strategy == "all"
|
|
996
1006
|
mock_tree = nil
|
|
997
|
-
if !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
|
|
1007
|
+
if !selective_replay && !dry_run && (mock_strategy == "all" || mock_strategy == "marked" || overrides_present)
|
|
998
1008
|
begin
|
|
999
1009
|
tree = http_client.get_span_tree(
|
|
1000
1010
|
original_span_id,
|
|
@@ -1035,6 +1045,7 @@ module Bitfab
|
|
|
1035
1045
|
span["id"],
|
|
1036
1046
|
metrics,
|
|
1037
1047
|
input_source_trace_id: span["externalTraceId"],
|
|
1048
|
+
selective_replay:,
|
|
1038
1049
|
mock_strategy:,
|
|
1039
1050
|
mock_tree:,
|
|
1040
1051
|
mock_overrides:,
|
|
@@ -1282,7 +1293,7 @@ module Bitfab
|
|
|
1282
1293
|
def execute_item(item, receiver, method_name, experiment_id, input_source_span_id = nil, metrics = {},
|
|
1283
1294
|
input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
|
|
1284
1295
|
fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, db_branch_timings: nil,
|
|
1285
|
-
source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false)
|
|
1296
|
+
source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil, attempt: 0, dry_run: false, selective_replay: nil)
|
|
1286
1297
|
args, kwargs = Serialize.deserialize_inputs(item)
|
|
1287
1298
|
|
|
1288
1299
|
fn_result = nil
|
|
@@ -1303,6 +1314,7 @@ module Bitfab
|
|
|
1303
1314
|
begin
|
|
1304
1315
|
ReplayContext.with_context(
|
|
1305
1316
|
replay_attempt: attempt,
|
|
1317
|
+
selective_replay:,
|
|
1306
1318
|
experiment_id:,
|
|
1307
1319
|
input_source_span_id:,
|
|
1308
1320
|
input_source_trace_id:,
|
|
@@ -1338,6 +1350,7 @@ module Bitfab
|
|
|
1338
1350
|
receiver.send(method_name, *args, **kwargs)
|
|
1339
1351
|
end
|
|
1340
1352
|
fn_result = fn_result.to_a if fn_result.is_a?(Enumerator)
|
|
1353
|
+
selective_replay&.assert_no_safety_conflict!
|
|
1341
1354
|
replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
|
|
1342
1355
|
rescue => e
|
|
1343
1356
|
# The method ran and raised, so it still has a duration.
|
|
@@ -1357,6 +1370,7 @@ module Bitfab
|
|
|
1357
1370
|
|
|
1358
1371
|
item_error = fn_error || replay_error
|
|
1359
1372
|
{
|
|
1373
|
+
**(selective_replay ? {selective_replay: selective_replay.report} : {}),
|
|
1360
1374
|
attempt:,
|
|
1361
1375
|
ingestion_type: metrics[:ingestion_type],
|
|
1362
1376
|
input: kwargs.empty? ? args : [*args, kwargs],
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bitfab
|
|
6
|
+
class SelectiveReplay
|
|
7
|
+
def self.wire_options(options)
|
|
8
|
+
valid = options.is_a?(Hash) && options.keys == [:must_run] && options[:must_run].is_a?(Array) &&
|
|
9
|
+
options[:must_run].length.between?(1, 100) && options[:must_run].all? do |node|
|
|
10
|
+
node.is_a?(Hash) && node.keys.sort == [:span_name, :trace_function_key] &&
|
|
11
|
+
node.values.all? { |value| value.is_a?(String) && value.length.between?(1, 500) }
|
|
12
|
+
end
|
|
13
|
+
raise ArgumentError, "experimental_selective_replay accepts only 1–100 must_run identities" unless valid
|
|
14
|
+
|
|
15
|
+
{"mustRun" => options[:must_run].map { |node| {"traceFunctionKey" => node[:trace_function_key], "spanName" => node[:span_name]} }}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Strict JSON avoids silently changing Ruby classes, symbol keys, numeric
|
|
19
|
+
# types, aliases, or insertion order when a recorded output is returned.
|
|
20
|
+
def self.fingerprint(value)
|
|
21
|
+
seen = Set.new
|
|
22
|
+
validate = lambda do |item|
|
|
23
|
+
case item
|
|
24
|
+
when NilClass, TrueClass, FalseClass
|
|
25
|
+
true
|
|
26
|
+
when String
|
|
27
|
+
item.instance_of?(String) && seen.add?(item.object_id) && !item.start_with?("<unserializable")
|
|
28
|
+
when Integer
|
|
29
|
+
item.abs <= (2**53 - 1)
|
|
30
|
+
when Float
|
|
31
|
+
item.finite? && item != item.to_i
|
|
32
|
+
when Array, Hash
|
|
33
|
+
next false unless item.instance_of?(Array) || item.instance_of?(Hash)
|
|
34
|
+
next false unless seen.add?(item.object_id)
|
|
35
|
+
|
|
36
|
+
if item.is_a?(Array)
|
|
37
|
+
item.all? { |child| validate.call(child) }
|
|
38
|
+
else
|
|
39
|
+
item.default.nil? && item.default_proc.nil? && !item.compare_by_identity? && item.all? do |key, child|
|
|
40
|
+
key.instance_of?(String) && !key.match?(/\A(?:0|[1-9][0-9]*)\z/) && validate.call(child)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
validate.call(value) ? JSON.generate(value) : nil
|
|
48
|
+
rescue StandardError, SystemStackError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.inputs(args, kwargs)
|
|
53
|
+
return nil unless kwargs.keys.all? { |key| key.is_a?(Symbol) }
|
|
54
|
+
|
|
55
|
+
fingerprint([args, kwargs.transform_keys(&:to_s)])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(plan, options)
|
|
59
|
+
expected = self.class.wire_options(options)
|
|
60
|
+
unless plan.is_a?(Hash) && plan["version"] == 4 && plan["nodes"].is_a?(Array) &&
|
|
61
|
+
plan["nodes"].length.between?(1, 500) && plan["assertions"].is_a?(Array) &&
|
|
62
|
+
plan["unresolvedMustRun"].is_a?(Array)
|
|
63
|
+
raise ArgumentError, "Server did not return a compatible selective replay plan"
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, "Selective replay plan does not match must_run" unless plan["options"] == expected
|
|
66
|
+
|
|
67
|
+
@plan = JSON.parse(JSON.generate(plan))
|
|
68
|
+
@required = expected["mustRun"].map { |node| key(node) }.to_set
|
|
69
|
+
@nodes = {}
|
|
70
|
+
@children = Hash.new { |hash, id| hash[id] = [] }
|
|
71
|
+
@recordings = {}
|
|
72
|
+
@parents = {}
|
|
73
|
+
@consumed = Hash.new { |hash, id| hash[id] = Set.new }
|
|
74
|
+
@required_scopes = Set.new
|
|
75
|
+
@assertion_scopes = Set.new
|
|
76
|
+
@assertion_names = Set.new
|
|
77
|
+
@unresolved_assertions = []
|
|
78
|
+
@observed = Hash.new(0)
|
|
79
|
+
@reached = Set.new
|
|
80
|
+
@decisions = []
|
|
81
|
+
@mutex = Mutex.new
|
|
82
|
+
@plan["nodes"].each do |node|
|
|
83
|
+
valid_node = node.is_a?(Hash) && node["id"].is_a?(String) && !@nodes.key?(node["id"]) &&
|
|
84
|
+
node["spanName"].is_a?(String) && node["traceFunctionKey"].is_a?(String) &&
|
|
85
|
+
[true, false].include?(node["mustRun"]) && [true, false].include?(node["reusable"]) &&
|
|
86
|
+
node.key?("parentId") && (node["parentId"].nil? || node["parentId"].is_a?(String))
|
|
87
|
+
raise ArgumentError, "Invalid selective replay node" unless valid_node
|
|
88
|
+
@nodes[node["id"]] = node
|
|
89
|
+
@children[node["parentId"]] << node unless node["parentId"].nil?
|
|
90
|
+
recording = node["recording"]
|
|
91
|
+
if recording.is_a?(Hash) && recording.key?("output")
|
|
92
|
+
@recordings[node["id"]] = [self.class.fingerprint([recording["input"], recording.fetch("kwargs", {})]), self.class.fingerprint(recording["output"])]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
root = @nodes[@plan["rootId"]]
|
|
96
|
+
raise ArgumentError, "Selective replay requires a live recorded root" unless root && root["parentId"].nil? && root["mustRun"]
|
|
97
|
+
|
|
98
|
+
@full_trace = false
|
|
99
|
+
ids = Set.new
|
|
100
|
+
@plan["assertions"].each do |assertion|
|
|
101
|
+
unless assertion.is_a?(Hash) && assertion["id"].is_a?(String) && !assertion["id"].empty? && ids.add?(assertion["id"]) && assertion.key?("target")
|
|
102
|
+
raise ArgumentError, "Invalid selective replay assertion"
|
|
103
|
+
end
|
|
104
|
+
target = assertion["target"]
|
|
105
|
+
if target.nil?
|
|
106
|
+
@full_trace = true
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
raise ArgumentError, "Invalid selective replay assertion target" unless target.is_a?(Hash) && %w[output span].include?(target["kind"])
|
|
110
|
+
next if target["kind"] == "output"
|
|
111
|
+
|
|
112
|
+
occurrence = target.fetch("occurrence", "first")
|
|
113
|
+
valid_target = target["name"].is_a?(String) && !target["name"].empty? &&
|
|
114
|
+
(%w[first last].include?(occurrence) || (occurrence.is_a?(Integer) && occurrence >= 0))
|
|
115
|
+
raise ArgumentError, "Invalid selective replay assertion target" unless valid_target
|
|
116
|
+
@assertion_names.add(target["name"])
|
|
117
|
+
count = @nodes.values.count { |node| node["spanName"] == target["name"] }
|
|
118
|
+
@unresolved_assertions << assertion["id"] if count <= (occurrence.is_a?(Integer) ? occurrence : 0)
|
|
119
|
+
end
|
|
120
|
+
@full_trace ||= !@unresolved_assertions.empty?
|
|
121
|
+
unresolved = @required - @nodes.values.map { |node| key(node) }.to_set
|
|
122
|
+
unless unresolved == @plan["unresolvedMustRun"].map { |node| key(node) }.to_set
|
|
123
|
+
raise ArgumentError, "Selective replay plan has incomplete must_run coverage"
|
|
124
|
+
end
|
|
125
|
+
@nodes.each_value do |node|
|
|
126
|
+
ancestors = Set.new([node["id"]])
|
|
127
|
+
required = @required.include?(key(node)) || !unresolved.empty?
|
|
128
|
+
asserted = @full_trace || @assertion_names.include?(node["spanName"])
|
|
129
|
+
parent_id = node["parentId"]
|
|
130
|
+
until parent_id.nil?
|
|
131
|
+
parent = @nodes[parent_id]
|
|
132
|
+
unless parent && ancestors.add?(parent_id)
|
|
133
|
+
raise ArgumentError, "Invalid selective replay ancestry"
|
|
134
|
+
end
|
|
135
|
+
if node["mustRun"] && !parent["mustRun"]
|
|
136
|
+
raise ArgumentError, "Invalid selective replay ancestry"
|
|
137
|
+
end
|
|
138
|
+
required ||= @required.include?(key(parent))
|
|
139
|
+
asserted ||= @assertion_names.include?(parent["spanName"])
|
|
140
|
+
parent_id = parent["parentId"]
|
|
141
|
+
end
|
|
142
|
+
if !ancestors.include?(@plan["rootId"]) || ((required || asserted) && !node["mustRun"])
|
|
143
|
+
raise ArgumentError, "Selective replay plan could hide required code or assertion evidence"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def enter(trace_function_key:, span_name:, span_id:, parent_span_id:, args:, kwargs:, safety_mock:, reuse_allowed:, can_reuse_output: true)
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
identity = [trace_function_key, span_name]
|
|
151
|
+
input = self.class.inputs(args, kwargs)
|
|
152
|
+
node = if parent_span_id.nil?
|
|
153
|
+
@nodes[@plan["rootId"]]
|
|
154
|
+
else
|
|
155
|
+
candidates = @children[@parents[parent_span_id]].select { |candidate| key(candidate) == identity && !@consumed[parent_span_id].include?(candidate["id"]) }
|
|
156
|
+
exact = candidates.select { |candidate| input && @recordings.dig(candidate["id"], 0) == input }
|
|
157
|
+
match = if exact.length == 1
|
|
158
|
+
exact.first
|
|
159
|
+
else
|
|
160
|
+
((exact.empty? && candidates.length == 1) ? candidates.first : nil)
|
|
161
|
+
end
|
|
162
|
+
(match ? [match] : candidates).each { |candidate| @consumed[parent_span_id].add(candidate["id"]) }
|
|
163
|
+
match
|
|
164
|
+
end
|
|
165
|
+
@parents[span_id] = node&.dig("id")
|
|
166
|
+
required = @required.include?(identity) || @required_scopes.include?(parent_span_id)
|
|
167
|
+
@required_scopes.add(span_id) if required
|
|
168
|
+
asserted = @full_trace || @assertion_names.include?(span_name) || @assertion_scopes.include?(parent_span_id)
|
|
169
|
+
@assertion_scopes.add(span_id) if asserted
|
|
170
|
+
must_run = parent_span_id.nil? || required || asserted || node&.dig("mustRun") || !@plan["unresolvedMustRun"].empty?
|
|
171
|
+
recorded_input, recorded_output = @recordings[node&.dig("id")]
|
|
172
|
+
reusable = input && input == recorded_input && recorded_output && can_reuse_output
|
|
173
|
+
reason = if required
|
|
174
|
+
"must-run"
|
|
175
|
+
elsif asserted
|
|
176
|
+
"assertion"
|
|
177
|
+
elsif !@plan["unresolvedMustRun"].empty?
|
|
178
|
+
"unresolved-must-run"
|
|
179
|
+
elsif !node
|
|
180
|
+
"unmatched-or-ambiguous-call"
|
|
181
|
+
elsif !reusable
|
|
182
|
+
"input-drift-or-missing-recording"
|
|
183
|
+
else
|
|
184
|
+
node["reason"]
|
|
185
|
+
end
|
|
186
|
+
decision = {trace_function_key:, span_name:, original_span_id: node&.dig("id"), reason:}
|
|
187
|
+
if safety_mock && (must_run || !reusable)
|
|
188
|
+
@decisions << decision.merge(action: "blocked", reason: "safety-conflict:#{reason}")
|
|
189
|
+
@failure = RuntimeError.new("Selective replay cannot execute or safely reuse safety-mocked node #{span_name} (#{reason})")
|
|
190
|
+
raise @failure
|
|
191
|
+
end
|
|
192
|
+
if !must_run && reusable && (safety_mock || (node["reusable"] && reuse_allowed))
|
|
193
|
+
@decisions << decision.merge(action: "mock", reason: safety_mock ? "safety-mock" : "unchanged-inputs")
|
|
194
|
+
return [true, JSON.parse(recorded_output)]
|
|
195
|
+
end
|
|
196
|
+
@decisions << decision.merge(action: "run")
|
|
197
|
+
@observed[span_name] += 1
|
|
198
|
+
@reached.add(identity) if @required.include?(identity)
|
|
199
|
+
[false, nil]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def assert_no_safety_conflict!
|
|
204
|
+
raise @failure if @failure
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def report
|
|
208
|
+
@mutex.synchronize do
|
|
209
|
+
{
|
|
210
|
+
decisions: @decisions.map(&:dup),
|
|
211
|
+
must_run_not_reached: @plan["options"]["mustRun"].reject { |node| @reached.include?(key(node)) }.map { |node| identity(node) },
|
|
212
|
+
unresolved_must_run: @plan["unresolvedMustRun"].map { |node| identity(node) },
|
|
213
|
+
assertion_ids: @plan["assertions"].map { |assertion| assertion["id"] },
|
|
214
|
+
unresolved_assertions: @unresolved_assertions.dup,
|
|
215
|
+
assertion_targets_not_reached: @plan["assertions"].filter_map do |assertion|
|
|
216
|
+
target = assertion["target"]
|
|
217
|
+
if target && target["kind"] == "span" && @observed[target["name"]] <= (target["occurrence"].is_a?(Integer) ? target["occurrence"] : 0)
|
|
218
|
+
assertion["id"]
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
private
|
|
226
|
+
|
|
227
|
+
def key(node)
|
|
228
|
+
[node.fetch("traceFunctionKey"), node.fetch("spanName")]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def identity(node)
|
|
232
|
+
{trace_function_key: node["traceFunctionKey"], span_name: node["spanName"]}
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Bitfab
|
|
4
4
|
class SimulationPlan
|
|
5
|
-
CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized prompt].freeze
|
|
5
|
+
CONTENT_KEYS = %w[input input_meta output output_meta input_serialized output_serialized replay_recording prompt].freeze
|
|
6
6
|
FRAMEWORKS = %w[openai-agents langgraph claude-agent-sdk vercel-ai].freeze
|
|
7
7
|
READ_TIMEOUT = 5.0
|
|
8
8
|
REFRESH_SECONDS = 60.0
|
data/lib/bitfab/subtree.rb
CHANGED
|
@@ -667,10 +667,15 @@ module Bitfab
|
|
|
667
667
|
active_parent[:span_id]
|
|
668
668
|
end
|
|
669
669
|
subtree_parent_span_id = stack.reverse_each.find { |entry| entry.span_id }&.span_id
|
|
670
|
+
parent_span_id = subtree_parent_span_id || @fiber_parents.dig(fiber_id, :span_id) || active_parent_span_id || @root_span_id
|
|
671
|
+
ReplayContext.current&.dig(:selective_replay)&.enter(
|
|
672
|
+
trace_function_key: @trace_function_key, span_name: metadata.name, span_id:, parent_span_id:,
|
|
673
|
+
args:, kwargs:, safety_mock: false, reuse_allowed: false, can_reuse_output: false
|
|
674
|
+
)
|
|
670
675
|
stack << OpenSpan.new(
|
|
671
676
|
identity: metadata.identity,
|
|
672
677
|
span_id:,
|
|
673
|
-
parent_span_id
|
|
678
|
+
parent_span_id:,
|
|
674
679
|
name: metadata.name,
|
|
675
680
|
args:,
|
|
676
681
|
kwargs:,
|
data/lib/bitfab/traceable.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Bitfab
|
|
|
53
53
|
# preserve the bound client through the fluent wrapper, matching Python's
|
|
54
54
|
# `BitfabFunction.span()` and TypeScript's `BitfabFunction.withSpan()`.
|
|
55
55
|
def self.wrap(klass, method_name, trace_function_key:, name: nil, type: "custom",
|
|
56
|
-
capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, client: nil, test_run_id: nil)
|
|
56
|
+
capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, client: nil, test_run_id: nil)
|
|
57
57
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
58
58
|
method_name_str = method_name.to_s
|
|
59
59
|
bound_client = client
|
|
@@ -67,7 +67,7 @@ module Bitfab
|
|
|
67
67
|
trace_function_key:,
|
|
68
68
|
span_name: name || Subtree.qualified_name(self, method_name),
|
|
69
69
|
span_type: type, function_name: method_name_str, args:, kwargs:,
|
|
70
|
-
capture_when:, mock_on_replay:, finalize:, experiment_id:, surface: :span,
|
|
70
|
+
capture_when:, mock_on_replay:, replay_reusable:, finalize:, experiment_id:, surface: :span, input_block: !block.nil?,
|
|
71
71
|
explicit_span_receiver: self, explicit_span_method_name: method_name) do
|
|
72
72
|
super(*args, **kwargs, &block)
|
|
73
73
|
end
|
|
@@ -135,7 +135,7 @@ module Bitfab
|
|
|
135
135
|
# Configure an existing method only when it is discovered beneath a
|
|
136
136
|
# bitfab_trace root. Outside subtree capture the method runs unchanged.
|
|
137
137
|
def self.node(klass, method_name, name: nil, type: "custom", capture: true,
|
|
138
|
-
experiment_id: nil, mock_on_replay: nil, finalize: nil, client: nil, test_run_id: nil)
|
|
138
|
+
experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, client: nil, test_run_id: nil)
|
|
139
139
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
140
140
|
validate_node_options!(capture:, mock_on_replay:)
|
|
141
141
|
method_name_str = method_name.to_s
|
|
@@ -164,7 +164,7 @@ module Bitfab
|
|
|
164
164
|
call.owner.client.send(:execute_span,
|
|
165
165
|
trace_function_key: call.owner.trace_function_key,
|
|
166
166
|
span_name:, span_type: type, function_name: method_name_str,
|
|
167
|
-
args:, kwargs:, capture_when: "nested", mock_on_replay: policy,
|
|
167
|
+
args:, kwargs:, capture_when: "nested", mock_on_replay: policy, replay_reusable:, input_block: !block.nil?,
|
|
168
168
|
explicit_span_receiver: self, explicit_span_method_name: method_name,
|
|
169
169
|
experiment_id:, finalize:, surface: :trace, result_recorded_elsewhere: call.recorded_elsewhere?, declared_node: true,
|
|
170
170
|
on_start: call.method(:start), on_complete: call.method(:complete),
|
|
@@ -267,7 +267,7 @@ module Bitfab
|
|
|
267
267
|
# When true, `client.replay(...)` returns this span's
|
|
268
268
|
# historical output instead of executing the wrapped method.
|
|
269
269
|
def bitfab_span(method_name, trace_function_key: nil, name: nil, type: "custom",
|
|
270
|
-
capture_when: "always", mock_on_replay: false, finalize: nil, experiment_id: nil, test_run_id: nil)
|
|
270
|
+
capture_when: "always", mock_on_replay: false, replay_reusable: false, finalize: nil, experiment_id: nil, test_run_id: nil)
|
|
271
271
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
272
272
|
trace_function_key ||= @bitfab_function_key
|
|
273
273
|
unless trace_function_key
|
|
@@ -278,7 +278,7 @@ module Bitfab
|
|
|
278
278
|
# If the method already exists (inline or after-method style), wrap it immediately
|
|
279
279
|
if method_defined?(method_name) || private_method_defined?(method_name)
|
|
280
280
|
_bitfab_wrap_method(
|
|
281
|
-
method_name, trace_function_key:, name:, type:, capture_when:, mock_on_replay:, finalize:, experiment_id:
|
|
281
|
+
method_name, trace_function_key:, name:, type:, capture_when:, mock_on_replay:, replay_reusable:, finalize:, experiment_id:
|
|
282
282
|
)
|
|
283
283
|
else
|
|
284
284
|
# Method doesn't exist yet (before-method style) - register for method_added hook
|
|
@@ -289,6 +289,7 @@ module Bitfab
|
|
|
289
289
|
type:,
|
|
290
290
|
capture_when:,
|
|
291
291
|
mock_on_replay:,
|
|
292
|
+
replay_reusable:,
|
|
292
293
|
finalize:,
|
|
293
294
|
experiment_id:
|
|
294
295
|
}
|
|
@@ -328,10 +329,10 @@ module Bitfab
|
|
|
328
329
|
# EXPERIMENTAL. Configure one method discovered beneath bitfab_trace.
|
|
329
330
|
# The node inherits the active trace key and never traces by itself.
|
|
330
331
|
def bitfab_node(method_name, name: nil, type: "custom", capture: true,
|
|
331
|
-
experiment_id: nil, mock_on_replay: nil, finalize: nil, test_run_id: nil)
|
|
332
|
+
experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil, test_run_id: nil)
|
|
332
333
|
experiment_id = Bitfab.resolve_experiment_id(experiment_id, test_run_id)
|
|
333
334
|
Traceable.send(:validate_node_options!, capture:, mock_on_replay:)
|
|
334
|
-
config = {name:, type:, capture:, experiment_id:, mock_on_replay:, finalize:}
|
|
335
|
+
config = {name:, type:, capture:, experiment_id:, mock_on_replay:, replay_reusable:, finalize:}
|
|
335
336
|
if method_defined?(method_name) || private_method_defined?(method_name)
|
|
336
337
|
_bitfab_node_method(method_name, **config)
|
|
337
338
|
else
|
|
@@ -365,7 +366,7 @@ module Bitfab
|
|
|
365
366
|
end
|
|
366
367
|
|
|
367
368
|
def _bitfab_node_method(method_name, name: nil, type: "custom", capture: true,
|
|
368
|
-
experiment_id: nil, mock_on_replay: nil, finalize: nil)
|
|
369
|
+
experiment_id: nil, mock_on_replay: nil, replay_reusable: false, finalize: nil)
|
|
369
370
|
Traceable.node(
|
|
370
371
|
self,
|
|
371
372
|
method_name,
|
|
@@ -374,6 +375,7 @@ module Bitfab
|
|
|
374
375
|
capture:,
|
|
375
376
|
experiment_id:,
|
|
376
377
|
mock_on_replay:,
|
|
378
|
+
replay_reusable:,
|
|
377
379
|
finalize:,
|
|
378
380
|
client: nil
|
|
379
381
|
)
|
data/lib/bitfab/version.rb
CHANGED
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.61.
|
|
4
|
+
version: 0.61.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Harvest Team
|
|
@@ -188,6 +188,7 @@ files:
|
|
|
188
188
|
- lib/bitfab/seed.rb
|
|
189
189
|
- lib/bitfab/seed_cli.rb
|
|
190
190
|
- lib/bitfab/seed_context.rb
|
|
191
|
+
- lib/bitfab/selective_replay.rb
|
|
191
192
|
- lib/bitfab/serialize.rb
|
|
192
193
|
- lib/bitfab/simulation_plan.rb
|
|
193
194
|
- lib/bitfab/span_context.rb
|