Module: Bitfab::Replay
- Defined in:
- lib/bitfab/replay.rb
Overview
Replay historical traces through a traced method and create a test run.
Constant Summary collapse
- CODE_CHANGE_UNSET =
Object.new.freeze
- PERSISTENCE_TIMEOUT_SECONDS =
30.0- PERSISTENCE_POLL_SECONDS =
0.1- TRUNK_CANDIDATES =
Read the code-change payload the replay wrapper captured and pointed us at via BITFAB_CODE_CHANGE_PATH. Returns [description, files] or nil. Best-effort: any error (no env var, missing file, bad JSON) yields nil so the replay proceeds with no code change rather than raising.
["origin/HEAD", "origin/main", "origin/master", "main", "master"].freeze
- CC_MAX_FILES =
60- CC_MAX_FILE_BYTES =
500_000- CC_MAX_TOTAL_BYTES =
2_000_000
Class Method Summary collapse
-
.build_mock_tree(root) ⇒ Object
Walk the children of a root span tree node depth-first and build a lookup keyed by "#trace_function_key:#span_name:#call_index".
-
.build_span_output_fetcher(http_client) ⇒ Object
Build a memoized lazy fetcher for a single replay item.
- .capture_code_change_from_git(cwd, label = nil) ⇒ Object
- .cc_blob_bytes(root, base, path) ⇒ Object
- .cc_looks_binary?(str) ⇒ Boolean
- .cc_parse_name_status_z(raw) ⇒ Object
- .cc_read_working_file(root, path) ⇒ Object
- .cc_ref_exists?(root, ref) ⇒ Boolean
-
.cc_resolve_base(root) ⇒ Object
Returns [base, from_trunk]; from_trunk is false on the HEAD fallback.
- .cc_working_bytes(root, path) ⇒ Object
-
.db_branch_enabled?(db_branch) ⇒ Boolean
Whether the caller asked for database branching.
-
.db_branch_settings(db_branch) ⇒ Object
Convert the caller's
db_branchoptions to the wire shape, or nil when nothing was set. -
.execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {}, input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil, fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil) ⇒ Object
Execute a single replay item: deserialize inputs, call method with replay context.
-
.extract_server_item_metrics(server_item) ⇒ Object
Pull durationMs / model from the start-replay server item.
-
.extract_span_data(span) ⇒ Object
Extract input/output data from an external span's rawData.
- .git(cwd, args) ⇒ Object
- .json_safe(value) ⇒ Object
-
.normalize_tokens(raw_tokens) ⇒ Object
Normalize a complete-replay tokens hash (string-keyed JSON) into the symbol-keyed shape the replay item exposes.
- .original_span_id_of(server_item) ⇒ Object
-
.original_trace_id_of(server_item) ⇒ Object
The ORIGINAL (historical) trace/span an item replays.
- .preserve_replay_failure(items, test_run_id, test_run_url) ⇒ Object
-
.process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil, mock_overrides: []) ⇒ Object
Process all replay items, optionally in parallel using threads.
-
.process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: []) ⇒ Object
Fetch span data and execute a single replay item.
- .public_replay_items(items) ⇒ Object
- .read_code_change_file ⇒ Object
-
.release_db_branch_lease(http_client, lease) ⇒ Object
Delete the per-item Neon preview branch.
- .replay_item_error_message(error) ⇒ Object
-
.resolve_auto_code_change(label = nil) ⇒ Object
Auto-capture the code change to attach when the caller passed none.
-
.run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil) ⇒ Hash
Replay historical traces through a method and create a test run.
-
.wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids) ⇒ Object
Block until the server has persisted every replay trace this run queued.
- .write_replay_result_file(result) ⇒ Object
Class Method Details
.build_mock_tree(root) ⇒ Object
Walk the children of a root span tree node depth-first and build a lookup keyed by "#trace_function_key:#span_name:#call_index".
The root node itself is excluded: at replay time the runtime root span never queries the mock tree.
The compound (key, name) match disambiguates same-key spans that come
from the fluent client.get_function(key).wrap(...) pattern: every
wrapped method shares trace_function_key but differs in span_name. The
counter is per-(key, name) pair so repeated same-name calls (including
recursion) still order by occurrence. Mirrors the Python and TypeScript
SDKs after HVT-2078: keying by trace_function_key alone caused the
wrong historical output for fluent-API span sets.
964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 |
# File 'lib/bitfab/replay.rb', line 964 def build_mock_tree(root) spans = {} counters = {} walk = lambda do |node| key = node["traceFunctionKey"] if key && !key.empty? name = node["spanName"] name = key if name.nil? || name.empty? counter_key = "#{key}:#{name}" index = counters[counter_key] || 0 counters[counter_key] = index + 1 # externalSpanId is what the lazy path fetches the recorded output by # when the tree came back payload-free (includeOutputs=false). output # / outputMeta are copied ONLY when the node actually carries them, so # the presence of the :output key distinguishes an inline nil output # from a payload-free entry (mirrors TS's undefined-vs-null check). entry = { source_span_id: node["sourceSpanId"], external_span_id: node["externalSpanId"] } entry[:output] = node["output"] if node.key?("output") entry[:output_meta] = node["outputMeta"] if node.key?("outputMeta") spans["#{counter_key}:#{index}"] = entry end (node["children"] || []).each { |child| walk.call(child) } end (root["children"] || []).each { |child| walk.call(child) } spans end |
.build_span_output_fetcher(http_client) ⇒ Object
Build a memoized lazy fetcher for a single replay item. The returned lambda takes an externalSpanId and returns that span's deserialized recorded output, fetching it via get_external_span at most once per id (a span read twice - as a base "marked" mock and via an override's get_original_output - fetches once). The cache is per item: each item replays synchronously on its own thread, so no lock is needed.
1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 |
# File 'lib/bitfab/replay.rb', line 1003 def build_span_output_fetcher(http_client) cache = {} lambda do |external_span_id| return cache[external_span_id] if cache.key?(external_span_id) span = http_client.get_external_span(external_span_id, replay_view: true) span_data = (span["rawData"] || {})["span_data"] || {} # Prefer the Ruby Marshal payload (output_serialized) written by this # SDK; fall back to another SDK's output_meta, then the raw JSON output. = span_data["output_serialized"] = span_data["output_meta"] if .nil? || == "" cache[external_span_id] = Serialize.deserialize_output(span_data["output"], ) end end |
.capture_code_change_from_git(cwd, label = nil) ⇒ Object
563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 |
# File 'lib/bitfab/replay.rb', line 563 def capture_code_change_from_git(cwd, label = nil) root = git(cwd, ["rev-parse", "--show-toplevel"]) return nil if root.nil? || root.strip.empty? root = root.strip resolved = cc_resolve_base(root) return nil if resolved.nil? base, from_trunk = resolved tracked = git(root, ["diff", "--name-status", "--no-renames", "-z", base, "--", ":!.bitfab"]) || "" untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z", "--", ":!.bitfab"]) || "" entries = cc_parse_name_status_z(tracked) untracked.split("\0").reject(&:empty?).each { |p| entries << ["A", p] } return nil if entries.empty? files = [] total = 0 entries.each do |status, path| break if files.length >= CC_MAX_FILES # Skip oversized files by size BEFORE reading their full contents, so a # huge changed file can't OOM/stall replay just to be discarded. before_bytes = (status == "A") ? 0 : cc_blob_bytes(root, base, path) after_bytes = (status == "D") ? 0 : cc_working_bytes(root, path) next if before_bytes > CC_MAX_FILE_BYTES || after_bytes > CC_MAX_FILE_BYTES # Normalize CRLF -> LF on both sides: git's blob is already LF-normalized # (autocrlf clean), so a raw CRLF working file would otherwise skew every # line as changed. Comparing/storing LF keeps the diff meaningful. before = (status == "A") ? "" : (git(root, ["show", "#{base}:#{path}"]) || "").gsub("\r\n", "\n") after = (status == "D") ? "" : cc_read_working_file(root, path).gsub("\r\n", "\n") next if before == after size = before.bytesize + after.bytesize next if total + size > CC_MAX_TOTAL_BYTES || cc_looks_binary?(before) || cc_looks_binary?(after) total += size files << {"path" => path, "before" => before, "after" => after} end return nil if files.empty? subject = git(root, ["log", "-1", "--format=%s", "HEAD"]) subject = subject ? subject.strip : "" head = ((label && !label.strip.empty?) ? label.strip : nil) || (subject.empty? ? nil : subject) || "Working-tree change" word = (files.length == 1) ? "file" : "files" against = from_trunk ? "vs trunk" : "uncommitted (vs HEAD)" ["#{head} (#{files.length} #{word} changed #{against})", files] rescue nil end |
.cc_blob_bytes(root, base, path) ⇒ Object
550 551 552 553 554 555 |
# File 'lib/bitfab/replay.rb', line 550 def cc_blob_bytes(root, base, path) out = git(root, ["cat-file", "-s", "#{base}:#{path}"]) (out && !out.strip.empty?) ? out.strip.to_i : 0 rescue 0 end |
.cc_looks_binary?(str) ⇒ Boolean
634 635 636 |
# File 'lib/bitfab/replay.rb', line 634 def cc_looks_binary?(str) str[0, 8000].include?("\0") end |
.cc_parse_name_status_z(raw) ⇒ Object
617 618 619 620 621 622 623 624 625 626 |
# File 'lib/bitfab/replay.rb', line 617 def cc_parse_name_status_z(raw) parts = raw.split("\0").reject(&:empty?) out = [] i = 0 while i + 1 < parts.length out << [parts[i][0], parts[i + 1]] i += 2 end out end |
.cc_read_working_file(root, path) ⇒ Object
628 629 630 631 632 |
# File 'lib/bitfab/replay.rb', line 628 def cc_read_working_file(root, path) File.read(File.join(root, path), encoding: "UTF-8") rescue "" end |
.cc_ref_exists?(root, ref) ⇒ Boolean
527 528 529 |
# File 'lib/bitfab/replay.rb', line 527 def cc_ref_exists?(root, ref) !git(root, ["rev-parse", "--verify", "#{ref}^{object}"]).nil? end |
.cc_resolve_base(root) ⇒ Object
Returns [base, from_trunk]; from_trunk is false on the HEAD fallback.
532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 |
# File 'lib/bitfab/replay.rb', line 532 def cc_resolve_base(root) forced = ENV["BITFAB_CODE_CHANGE_BASE"] if forced && cc_ref_exists?(root, forced) mb = git(root, ["merge-base", "HEAD", forced]) return [mb.strip, true] if mb && !mb.strip.empty? rp = git(root, ["rev-parse", "--verify", forced]) return (rp && !rp.strip.empty?) ? [rp.strip, true] : nil end TRUNK_CANDIDATES.each do |candidate| next unless cc_ref_exists?(root, candidate) mb = git(root, ["merge-base", "HEAD", candidate]) return [mb.strip, true] if mb && !mb.strip.empty? end cc_ref_exists?(root, "HEAD") ? ["HEAD", false] : nil end |
.cc_working_bytes(root, path) ⇒ Object
557 558 559 560 561 |
# File 'lib/bitfab/replay.rb', line 557 def cc_working_bytes(root, path) File.size(File.join(root, path)) rescue 0 end |
.db_branch_enabled?(db_branch) ⇒ Boolean
Whether the caller asked for database branching. true and a Hash both
turn it on; false and nil leave it off. Kept separate from
db_branch_settings because db_branch: true enables branching while
contributing no settings, so the presence of settings can't stand in for
the switch.
446 447 448 |
# File 'lib/bitfab/replay.rb', line 446 def db_branch_enabled?(db_branch) !db_branch.nil? && db_branch != false end |
.db_branch_settings(db_branch) ⇒ Object
Convert the caller's db_branch options to the wire shape, or nil when
nothing was set. Dropping the key entirely keeps the request identical to
what older SDKs send. Booleans carry no settings: they only move the
switch.
Keys: :min_cu and :max_cu are the branch compute's autoscaling floor and ceiling in Neon Compute Units (equal values pin the size, keeping items comparable); :warmup_sql is appended to the branch's readiness check so it warms the cache before the replayed function sees the lease.
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 |
# File 'lib/bitfab/replay.rb', line 459 def db_branch_settings(db_branch) return nil if db_branch.nil? || db_branch == true || db_branch == false || db_branch.empty? # Symbol or string keys, matching normalize_code_change_files: a hash # assembled from JSON or YAML would otherwise be read as empty, leaving # the branch on the mirror's defaults with no error to notice. min_cu = db_branch[:min_cu] || db_branch["min_cu"] max_cu = db_branch[:max_cu] || db_branch["max_cu"] warmup_sql = db_branch[:warmup_sql] || db_branch["warmup_sql"] settings = {} settings["minCu"] = min_cu unless min_cu.nil? settings["maxCu"] = max_cu unless max_cu.nil? settings["warmupSql"] = warmup_sql unless warmup_sql.nil? settings.empty? ? nil : settings end |
.execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {}, input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil, fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil) ⇒ Object
Execute a single replay item: deserialize inputs, call method with replay context.
1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 |
# File 'lib/bitfab/replay.rb', line 1120 def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {}, input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil, fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil) args, kwargs = Serialize.deserialize_inputs(item) fn_result = nil fn_error = nil replay_error = nil # Client-side correlation id that tags this item's replay spans so the # server can echo back the row id it minted (resolved in run()'s # complete-replay loop). Carried on the item under :_sdk_trace_id, never # surfaced as the public :trace_id. sdk_trace_id = SecureRandom.uuid # Declared before this item's first span is submitted: the transport # records delivery only for traces someone asked about, so anything # submitted before this would go untracked. http_client&.track_trace_deliveries([sdk_trace_id]) begin ReplayContext.with_context( test_run_id:, input_source_span_id:, input_source_trace_id:, trace_id: sdk_trace_id, mock_tree:, mock_strategy:, mock_overrides:, fetch_span_output:, db_branch_lease:, source_bitfab_trace_id: ) do begin if adapt_inputs ctx = adapt_ctx || { original_trace_id: nil, original_span_id: input_source_span_id, # Deprecated aliases for original_trace_id/original_span_id. source_trace_id: nil, source_span_id: input_source_span_id } args, kwargs = adapt_inputs.call(args, kwargs, ctx) end rescue => e replay_error = e end if replay_error.nil? begin fn_result = if kwargs.empty? receiver.send(method_name, *args) else receiver.send(method_name, *args, **kwargs) end rescue => e fn_error = e end end end rescue => e replay_error = e end item_error = fn_error || replay_error { input: args, result: fn_result, original_output: item["output"], error: item_error&., trace_error: fn_error, replay_error:, duration_ms: metrics[:duration_ms], tokens: metrics[:tokens], model: metrics[:model], # Written in by run() from the complete-replay response once the server # has minted this replay trace's row. Nil until then: the client-side # correlation id (below) is never surfaced as the public :trace_id. trace_id: nil, _sdk_trace_id: sdk_trace_id, original_trace_id: source_bitfab_trace_id, original_span_id: input_source_span_id, # Deprecated aliases for original_trace_id/original_span_id. source_trace_id: source_bitfab_trace_id, source_span_id: input_source_span_id, db_snapshot_ref: } end |
.extract_server_item_metrics(server_item) ⇒ Object
Pull durationMs / model from the start-replay server item. Nil-safe defaults so older servers without these fields still produce a consistent shape. Tokens are intentionally NOT read from the start item (it carries the ORIGINAL trace's tokens); the replayed run's tokens are filled in by run() from the complete-replay response once spans are aggregated server-side, and stay nil here and on older servers.
1037 1038 1039 1040 1041 1042 1043 |
# File 'lib/bitfab/replay.rb', line 1037 def extract_server_item_metrics(server_item) { duration_ms: server_item["durationMs"], tokens: nil, model: server_item["model"] } end |
.extract_span_data(span) ⇒ Object
Extract input/output data from an external span's rawData.
1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 |
# File 'lib/bitfab/replay.rb', line 1019 def extract_span_data(span) raw_data = span["rawData"] || {} span_data = raw_data["span_data"] || {} { "input" => span_data["input"], "output" => span_data["output"], "inputSerialized" => span_data["input_serialized"], "outputSerialized" => span_data["output_serialized"] } end |
.git(cwd, args) ⇒ Object
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 |
# File 'lib/bitfab/replay.rb', line 510 def git(cwd, args) # capture3 so git's stderr (e.g. "not a git repository") is swallowed, not # leaked to the host process's console. out, _err, status = Timeout.timeout(30) do Open3.capture3("git", *args, chdir: cwd) end return nil unless status.success? # Tag as UTF-8 and scrub invalid bytes so blob contents compare cleanly # against the UTF-8 working-file reads and never break JSON.generate later # (a non-UTF-8 file that slips past the null-byte check would otherwise # abort the whole start-replay request). out.force_encoding("UTF-8").scrub rescue nil end |
.json_safe(value) ⇒ Object
112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/bitfab/replay.rb', line 112 def json_safe(value) case value when Exception serialized = {type: value.class.name, message: value., backtrace: value.backtrace} if value.is_a?(DbBranchReplayError) serialized[:code] = value.code serialized[:original_trace_id] = value.original_trace_id serialized[:cause] = json_safe(value.cause) if value.cause end serialized when Hash value.to_h { |key, child| [key, json_safe(child)] } when Array value.map { |child| json_safe(child) } else value end end |
.normalize_tokens(raw_tokens) ⇒ Object
Normalize a complete-replay tokens hash (string-keyed JSON) into the symbol-keyed shape the replay item exposes. Nil when the server reported no token data for this trace.
1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 |
# File 'lib/bitfab/replay.rb', line 1108 def normalize_tokens(raw_tokens) return nil unless raw_tokens.is_a?(Hash) { input: raw_tokens["input"], output: raw_tokens["output"], cached: raw_tokens["cached"], total: raw_tokens["total"] } end |
.original_span_id_of(server_item) ⇒ Object
936 937 938 |
# File 'lib/bitfab/replay.rb', line 936 def original_span_id_of(server_item) server_item["originalSpanId"] || server_item["sourceSpanId"] end |
.original_trace_id_of(server_item) ⇒ Object
The ORIGINAL (historical) trace/span an item replays. Canonical server keys are originalTraceId/originalSpanId; older servers send them under the deprecated sourceTraceId/sourceSpanId aliases.
932 933 934 |
# File 'lib/bitfab/replay.rb', line 932 def original_trace_id_of(server_item) server_item["originalTraceId"] || server_item["sourceTraceId"] end |
.preserve_replay_failure(items, test_run_id, test_run_url) ⇒ Object
144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/bitfab/replay.rb', line 144 def preserve_replay_failure(items, test_run_id, test_run_url) yield rescue => cause error = ReplayError.new( cause., items: public_replay_items(items), test_run_id:, test_run_url: ) raise error, cause: end |
.process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil, mock_overrides: []) ⇒ Object
Process all replay items, optionally in parallel using threads.
652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 |
# File 'lib/bitfab/replay.rb', line 652 def process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, on_item_start: nil, on_item_finish: nil, mock_overrides: []) concurrency = max_concurrency || server_items.length # Lifecycle callbacks run from worker threads in the parallel path, so the # mutex makes counter updates safe and serializes each callback. A raising # callback is swallowed because progress UI must never crash the run. total = server_items.length progress_mutex = Mutex.new started = 0 completed = 0 succeeded = 0 errored = 0 # Each event carries the single item that just finished so a progress UI # can render per-trace pass/fail as the run streams. The item's :trace_id # is the server's assigned traces.id, read back off the ingest response and # surfaced as this item finishes (its trace is flushed on finish); nil only # if that flush could not confirm delivery in time (the end-of-run barrier # then fills the returned item), and the client correlation id is never # surfaced. original_trace_id (the historical trace being replayed, taken # from the server item) is what a UI keys on to identify what just finished. # source_trace_id/source_span_id are kept as deprecated aliases. report_start = lambda do |original_trace_id, original_span_id| progress_mutex.synchronize do started += 1 next unless on_item_start begin on_item_start.call({ type: "started", test_run_id:, started:, completed:, total:, succeeded:, errored:, item: { original_trace_id:, original_span_id:, source_trace_id: original_trace_id, source_span_id: original_span_id } }) rescue => e warn "Bitfab: replay on_item_start callback raised: #{e.}" end end end # Flush an item's trace the moment it finishes, so its server traces.id is # read back off the ingest response and surfaced on the item as it settles, # instead of waiting for the end-of-run barrier. Waiting until the end is # worst at low concurrency: each trace is closed and ready when its item # finishes, but nothing fills a batch to force an export, so ready ids sit # in the queue for the whole run. Serializing on a mutex keeps concurrent # finishers from storming the server: a flush drains the whole queue, so # the first finisher exports the batch and the rest find nothing to send. flush_mutex = Mutex.new flush_finished_item_trace = lambda do flush_mutex.synchronize { Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS) } rescue => e warn "Bitfab: replay per-item flush failed: #{e.}" end report = lambda do |result, original_trace_id, original_span_id, test_run_id| # Deliver this item's trace now, then read its server id back off the # ingest response. A flush failure never crashes the run: the id stays # nil and the end-of-run barrier remains the authority on persistence. flush_finished_item_trace.call result[:trace_id] = http_client.peek_server_trace_id(result[:_sdk_trace_id]) || result[:trace_id] progress_mutex.synchronize do completed += 1 error = result[:error] error.nil? ? (succeeded += 1) : (errored += 1) next unless on_item_finish begin on_item_finish.call({ test_run_id:, completed:, total:, succeeded:, errored:, item: { trace_id: result[:trace_id], original_trace_id:, original_span_id:, # Deprecated aliases for original_trace_id/original_span_id. source_trace_id: original_trace_id, source_span_id: original_span_id, input: result[:input], result: result[:result], original_output: result[:original_output], error:, trace_error: result[:trace_error], replay_error: result[:replay_error], duration_ms: result[:duration_ms], tokens: result[:tokens], model: result[:model], db_snapshot_ref: result[:db_snapshot_ref] } }) rescue => e warn "Bitfab: replay on_item_finish callback raised: #{e.}" end end end if concurrency <= 1 server_items.map do |item| report_start.call(original_trace_id_of(item), original_span_id_of(item)) result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:) report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id) result end else results_mutex = Mutex.new results = [] work_queue = server_items.each_with_index.to_a work_mutex = Mutex.new workers = [concurrency, server_items.length].min.times.map do Thread.new do loop do item, idx = work_mutex.synchronize { work_queue.shift } break unless item report_start.call(original_trace_id_of(item), original_span_id_of(item)) result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs, include_db_branch_lease, db_branch_settings, mock_overrides:) results_mutex.synchronize { results[idx] = result } report.call(result, original_trace_id_of(item), original_span_id_of(item), test_run_id) end end end workers.each(&:join) results.compact end end |
.process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: []) ⇒ Object
Fetch span data and execute a single replay item.
Any error while fetching the span, building the mock tree, or deserializing inputs is captured on the returned item's :error rather than propagated, so one bad trace never aborts the whole replay run (mirrors the TypeScript and Python SDKs' per-item rescue).
791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 |
# File 'lib/bitfab/replay.rb', line 791 def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, db_branch_settings = nil, mock_overrides: []) metrics = extract_server_item_metrics(server_item) # The ORIGINAL (historical) trace/span this item replays. Canonical # keys are originalTraceId/originalSpanId; older servers send them under # the deprecated sourceTraceId/sourceSpanId aliases (see *_of helpers). original_trace_id = original_trace_id_of(server_item) original_span_id = original_span_id_of(server_item) # The server resolves a Neon preview branch per item during /replay/start # (only when include_db_branch_lease was sent). Release it in the +ensure+ # below so any raise (span fetch, mock-tree build, or the replayed # method) frees the Neon resource. Items whose source trace had no # snapshot ref arrive without a lease (env.active? is false), and # replaying those against the live database is correct. lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil # A resolve that was ATTEMPTED and failed is different: the caller asked # for a branch, so running their method against live data would produce a # result that looks valid and is not. Fail the item instead, loudly. lease_error = include_db_branch_lease ? server_item["dbBranchLeaseError"] : nil db_snapshot_ref = server_item["dbSnapshotRef"] if include_db_branch_lease && lease.nil? && lease_error.nil? begin resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings) rescue => cause error = DbBranchReplayError.new( "lease_request_failed", "Bitfab could not request the database branch: #{cause.}", original_trace_id ) raise error, cause: end lease = resolved["lease"] lease_error = resolved["leaseError"] db_snapshot_ref = resolved["dbSnapshotRef"] || db_snapshot_ref end if lease_error raise DbBranchReplayError.new( lease_error["code"].to_s, lease_error["message"].to_s, original_trace_id ) end span = http_client.get_external_span(original_span_id, replay_view: true) item_data = extract_span_data(span) # Fetch the span tree when the base strategy needs recorded outputs # ("all"/"marked") OR when selective mock overrides are present. Overrides # gate on the same non-root call-counter machinery the tree drives, so # the tree must be fetched for them to fire even under mock: "none". # # Only mock: "all" needs every span's recorded output inline (it mocks # every child), so it fetches an eager tree. "marked" and overrides mock # only a few spans, so they fetch a payload-free tree (includeOutputs= # false) and pull each mocked span's output lazily by externalSpanId, # never dragging down outputs that no span consumes. overrides_present = !mock_overrides.nil? && !mock_overrides.empty? include_outputs = mock_strategy == "all" mock_tree = nil if mock_strategy == "all" || mock_strategy == "marked" || overrides_present begin tree = http_client.get_span_tree( original_span_id, include_outputs:, include_root_output: false ) mock_tree = build_mock_tree(tree["root"] || {}) rescue Exception => e # rubocop:disable Lint/RescueException raise if e.is_a?(SystemExit) || e.is_a?(SignalException) # "all" and overrides both depend on the tree: "all" mocks every span # from it, and overrides gate on its call-counter machinery. If the # fetch fails, surface the error on the item rather than silently # running everything real with the overrides dropped. Only bare # "marked" can fall back to real execution (its marked spans just # re-run). Mirrors the Python SDK's has_overrides re-raise. raise if mock_strategy == "all" || overrides_present mock_tree = nil end end # Memoized per-item lazy fetcher, present only on the payload-free path # (not eager "all", whose outputs are already inline). Passed to the # replay context so resolve_recorded_output can pull a mocked span's # output on demand, once per span even if read twice (base mock + an # override's get_original_output). fetch_span_output = (mock_tree && !include_outputs) ? build_span_output_fetcher(http_client) : nil adapt_ctx = { original_trace_id:, original_span_id:, # Deprecated aliases for original_trace_id/original_span_id. source_trace_id: original_trace_id, source_span_id: original_span_id } execute_item( item_data, receiver, method_name, test_run_id, span["id"], metrics, input_source_trace_id: span["externalTraceId"], mock_strategy:, mock_tree:, mock_overrides:, fetch_span_output:, adapt_inputs:, adapt_ctx:, db_branch_lease: lease, source_bitfab_trace_id: original_trace_id, db_snapshot_ref:, http_client: ) rescue => e warn "Bitfab: replay item for span #{original_span_id} failed before execution: #{e.}" { input: [], result: nil, original_output: nil, error: (e), trace_error: nil, replay_error: e, duration_ms: metrics&.dig(:duration_ms), tokens: metrics&.dig(:tokens), model: metrics&.dig(:model), trace_id: nil, original_trace_id:, original_span_id:, # Deprecated aliases for original_trace_id/original_span_id. source_trace_id: original_trace_id, source_span_id: original_span_id, db_snapshot_ref: } ensure release_db_branch_lease(http_client, lease) if lease end |
.public_replay_items(items) ⇒ Object
140 141 142 |
# File 'lib/bitfab/replay.rb', line 140 def public_replay_items(items) items.map { |item| item.except(:_sdk_trace_id) } end |
.read_code_change_file ⇒ Object
490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 |
# File 'lib/bitfab/replay.rb', line 490 def read_code_change_file path = ENV["BITFAB_CODE_CHANGE_PATH"] return nil if path.nil? || path.empty? begin parsed = JSON.parse(File.read(path)) files = parsed["files"] # A malformed payload (non-array, or entries that aren't hashes) must # yield no code change, never crash normalize_code_change_files. files = nil unless files.is_a?(Array) && files.all?(Hash) description = parsed["description"] description = nil unless description.is_a?(String) return nil if files.nil? && description.nil? [description, files] rescue nil end end |
.release_db_branch_lease(http_client, lease) ⇒ Object
Delete the per-item Neon preview branch. Best-effort: a failure is warned but never raised: the server-side TTL janitor reaps orphans.
942 943 944 945 946 947 948 949 |
# File 'lib/bitfab/replay.rb', line 942 def release_db_branch_lease(http_client, lease) neon_branch_id = lease["neonBranchId"] return unless neon_branch_id http_client.release_db_branch_lease(neon_branch_id) rescue => e warn "Bitfab: failed to release DB branch #{neon_branch_id} (TTL janitor will catch it): #{e.}" end |
.replay_item_error_message(error) ⇒ Object
131 132 133 134 135 136 137 138 |
# File 'lib/bitfab/replay.rb', line 131 def (error) return error. unless error.is_a?(DbBranchReplayError) "Replay requested a database branch for trace #{error.original_trace_id} but it " \ "could not be resolved (#{error.code}): #{error.}. The method was not run, " \ "because replaying it against the live database would produce a result that looks " \ "valid but did not use the historical data you asked for." end |
.resolve_auto_code_change(label = nil) ⇒ Object
Auto-capture the code change to attach when the caller passed none. Lives in replay() (the one call guaranteed to run on every replay) so it works no matter how the replay was launched. Precedence: a BITFAB_CODE_CHANGE_PATH override, else the git diff vs trunk. Best-effort: any failure yields nil. An explicit code_change_files always wins over this.
481 482 483 484 485 486 487 488 |
# File 'lib/bitfab/replay.rb', line 481 def resolve_auto_code_change(label = nil) return nil if ENV["BITFAB_DISABLE_CODE_CHANGE_CAPTURE"] from_env = read_code_change_file return from_env unless from_env.nil? capture_code_change_from_git(Dir.pwd, label) end |
.run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil) ⇒ Hash
Replay historical traces through a method and create a test run.
Fetches the last N traces for the given trace function key, re-runs each through the provided receiver and method, and returns comparison data.
224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 |
# File 'lib/bitfab/replay.rb', line 224 def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: CODE_CHANGE_UNSET, code_change_files: CODE_CHANGE_UNSET, experiment_group_id: nil, dataset_id: nil, grader_ids: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, db_branch: nil, on_item_start: nil, on_item_finish: nil, on_progress: nil) unless MOCK_STRATEGIES.include?(mock.to_s) raise ArgumentError, "Invalid mock strategy '#{mock}'. Must be one of: #{MOCK_STRATEGIES.join(", ")}" end item_finish_callback = on_item_finish || on_progress if trace_ids raise ArgumentError, "trace_ids must contain at least one trace ID." if trace_ids.empty? if trace_ids.length > 100 raise ArgumentError, "trace_ids supports at most 100 trace IDs per replay (got #{trace_ids.length})." end end if limit && trace_ids warn "Bitfab: limit is ignored when trace_ids is passed: the explicit trace ID list already " \ "determines how many traces replay." end # Reject a trace_function_key that contradicts the method's declared key. # replay() fetches historical traces by trace_function_key but records the # replayed spans under the method's own bitfab_span key (via send below), # so a mismatch produces an incoherent test run (traces fetched for one # function, recorded under another). Only fires when the method's key is # introspectable; an untraced method falls through to the persistence # check in complete_replay. Mirrors the TypeScript/Python SDKs. declared_key = Traceable.trace_function_key_for(receiver, method_name) if declared_key && declared_key != trace_function_key raise ArgumentError, "Method #{method_name} is traced under trace function key '#{declared_key}' but replay was " \ "called with '#{trace_function_key}'. Pass trace_function_key: '#{declared_key}' to replay it, " \ "or point at the method traced under '#{trace_function_key}'." end http_client = client.instance_variable_get(:@http_client) # Resolved override list: per-call overrides FIRST (they win), then the # client's registered overrides. First matching override wins downstream, # so this ordering makes per-call take precedence over registered, and # both take precedence over the base mock strategy. registered_overrides = client.instance_variable_get(:@mock_overrides) || [] resolved_overrides = MockOverride.normalize(mock_override) + registered_overrides # limit is meaningless with explicit trace_ids (the ID list determines # the count), so it's omitted from the request entirely. effective_limit = trace_ids ? nil : (limit || 5) include_db_branch_lease = db_branch_enabled?(db_branch) # code_change_files controls capture: omitted auto-captures, an array # wins, and explicit nil opts out. Preserve a caller-supplied description # while filling only the omitted files. if code_change_files.equal?(CODE_CHANGE_UNSET) captured = resolve_auto_code_change(name) unless captured.nil? code_change_files = captured[1] code_change_description = captured[0] if code_change_description.equal?(CODE_CHANGE_UNSET) end end code_change_description = nil if code_change_description.equal?(CODE_CHANGE_UNSET) code_change_files = nil if code_change_files.equal?(CODE_CHANGE_UNSET) resolved_db_branch_settings = db_branch_settings(db_branch) replay_data = http_client.start_replay( trace_function_key, effective_limit, trace_ids:, name:, code_change_description:, code_change_files:, experiment_group_id:, include_db_branch_lease:, dataset_id:, grader_ids:, db_branch_settings: resolved_db_branch_settings ) test_run_id = replay_data["testRunId"] test_run_url = replay_data["testRunUrl"] full_test_run_url = "#{client.service_url}#{test_run_url}" server_items = replay_data["items"] || [] result_items = if server_items.any? process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock.to_s, adapt_inputs, include_db_branch_lease, resolved_db_branch_settings, on_item_start:, on_item_finish: item_finish_callback, mock_overrides: resolved_overrides) else [] end # Spans and completions ride a batched transport, so the run is only safe # to finalize once the server confirms every replay trace it queued: the # trace-ID mapping complete_replay builds would otherwise race the # in-flight batches and hand back nil for every item. delivered_trace_ids = preserve_replay_failure(result_items, test_run_id, full_test_run_url) do wait_for_replay_persistence(http_client, test_run_id, result_items.map { |item| item[:_sdk_trace_id] }) end # Primary source for item[:trace_id]: the server's assigned traces.id, read # back per trace off the ingest response during the flush above (server- # sourced, no polling, already in hand before complete_replay). The # complete_replay map below remains the fallback for servers that don't # return ids on ingest. result_items.each do |item| local_id = item[:_sdk_trace_id] read_back = local_id && delivered_trace_ids[local_id] item[:trace_id] = read_back if read_back end # complete_replay finalizes the run and returns the token/diagnostic # mapping; its failures propagate loudly because a run that never # completed can't be finalized. complete_response = preserve_replay_failure(result_items, test_run_id, full_test_run_url) do http_client.complete_replay(test_run_id) end trace_id_map = complete_response&.dig("traceIds") # Per-replay-trace token usage keyed by server trace id: the REPLAYED # run's tokens (span-aggregated server-side), used below to fill each # item's :tokens. replay_tokens = complete_response&.dig("tokens") || {} # trace_id_map maps each item's client-side correlation id (:_sdk_trace_id, # which tagged that item's spans during the run) to the server's trace row # id. We use it to write the real server replay trace id into item[:trace_id] # now that the row exists, attach each item's server-aggregated token usage, # and detect a systemic upload failure early. Verdict persistence does NOT # use this map: it is keyed by the original-trace lineage (:original_trace_id + # test_run_id), which needs no client-held server id. Older servers that omit # the map yield no replay tokens and leave item[:trace_id] nil. unless trace_id_map.nil? missing = [] completed_count = 0 result_items.each do |item| local_id = item[:_sdk_trace_id] mapped = local_id && trace_id_map[local_id] # Fallback fill for servers that did not return ids on the ingest # response; the read-back loop above already set it when they did, so # never overwrite a resolved id with nil here. item[:trace_id] ||= mapped if item[:error].nil? completed_count += 1 missing << local_id if mapped.nil? end item[:tokens] = normalize_tokens(replay_tokens[mapped]) if mapped end # ALL completed items missing: systemic (the replayed method is not # traced, or uploads are wholesale broken). Raise; the run's traces # never persisted, so nothing can be labeled. if completed_count.positive? && missing.length == completed_count trace_count = complete_response["traceCount"] server_count = trace_count.nil? ? "" : " The server persisted #{trace_count} trace(s) for this run." cause = RuntimeError.new("Replay completed but the server has no persisted trace for " \ "any of the #{completed_count} completed item(s) " \ "(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \ "flushed, so either the uploads failed or the replayed method is " \ "not traced (no root span was emitted).") error = ReplayError.new( cause., items: public_replay_items(result_items), test_run_id:, test_run_url: full_test_run_url ) raise error, cause: end # SOME completed items missing: per-item upload failure. Warn, but # return the run; the items that landed can still be labeled. if missing.any? warn "Bitfab: server has no persisted trace for #{missing.length} of " \ "#{completed_count} completed replay item(s) " \ "(test_run_id #{test_run_id}). Their replay token usage is " \ "unavailable and they cannot be labeled." end end # Strip the internal correlation handle so returned items expose only the # public shape (runs against older servers that omit the map too). result_items.each { |item| item.delete(:_sdk_trace_id) } result = { items: result_items, test_run_id:, test_run_url: full_test_run_url } # Persist the enriched result so the Bitfab plugin never has to parse the # replay's stdout, which a dependency's logging can corrupt. write_replay_result_file(result) # Preserve the legacy terminal event only for on_progress. on_item_finish # is strictly item-scoped, so every invocation always includes an item. if on_item_finish.nil? && on_progress errored = result_items.count { |item| !item[:error].nil? } total = result_items.length begin on_progress.call({ type: "complete", test_run_id:, completed: total, total:, succeeded: total - errored, errored:, result: }) rescue => e warn "Bitfab: replay on_progress callback raised: #{e.}" end end result end |
.wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids) ⇒ Object
Block until the server has persisted every replay trace this run queued.
In the normal case the verdict is local: ingestion commits a request's carriers before it answers, so a flush that delivered every carrier has already proven the traces are whole and polling only asked the server to repeat itself. Acks cannot settle a request that timed out client-side after the server committed, so anything short of fully delivered falls through to the server poll, which stays the authority there.
1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 |
# File 'lib/bitfab/replay.rb', line 1053 def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids) trace_ids = sdk_trace_ids.compact # Checked before flushing: a run whose traces never closed submitted # nothing to wait on, and must not emit an export request to discover it. unless http_client.closed_deliveries?(trace_ids) http_client.take_trace_deliveries(trace_ids) return {} end # A failed flush is a hint, not a verdict. It means delivery was not # CONFIRMED within the deadline, which is not the same as lost: the export # timeout can fire while requests are still in flight and the server goes # on to persist every one of them. Failing here failed runs whose data had # fully landed. flushed = Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS) raw_deliveries = http_client.take_trace_deliveries(trace_ids) # Server-assigned traces.id per replay trace, read back off the ingest # response during the flush and keyed by the client-side replay id. read_back_trace_ids = raw_deliveries.each_with_object({}) do |(id, report), acc| acc[id] = report.server_trace_id unless report.server_trace_id.nil? end deliveries = raw_deliveries.select { |_id, report| report.closed } expected_span_counts = deliveries.transform_values(&:span_count) return read_back_trace_ids if expected_span_counts.empty? || deliveries.each_value.all?(&:delivered) deadline = Otel.monotonic_now + PERSISTENCE_TIMEOUT_SECONDS missing = expected_span_counts.keys while missing.any? status = http_client.get_replay_status(test_run_id, expected_span_counts) ready = status["traceIds"] ready = {} unless ready.is_a?(Hash) missing = expected_span_counts.keys - ready.keys return read_back_trace_ids if missing.empty? break if Otel.monotonic_now >= deadline sleep((deadline - Otel.monotonic_now).clamp(0, PERSISTENCE_POLL_SECONDS)) end cause = if flushed "" else " Delivery was also not confirmed before the flush deadline, so the " \ "spans likely never reached the server." end raise "Replay traces were not fully persisted before the delivery deadline " \ "(test_run_id #{test_run_id}, missing #{missing.length} of " \ "#{expected_span_counts.length} trace(s)).#{cause}" end |
.write_replay_result_file(result) ⇒ Object
638 639 640 641 642 643 644 645 646 647 648 649 |
# File 'lib/bitfab/replay.rb', line 638 def write_replay_result_file(result) result_path = ENV["BITFAB_REPLAY_RESULT_PATH"] return if result_path.nil? || result_path.empty? begin dir = File.dirname(result_path) FileUtils.mkdir_p(dir) unless dir.nil? || dir.empty? || dir == "." File.write(result_path, "#{Bitfab.serialize_replay_result(result)}\n") rescue => e warn "Bitfab: failed to write replay result to BITFAB_REPLAY_RESULT_PATH (#{result_path}): #{e.}" end end |