Module: Bitfab::Replay
- Defined in:
- lib/bitfab/replay.rb
Overview
Replay historical traces through a traced method and create a test run.
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.
-
.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) ⇒ 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.
-
.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.
-
.process_items(http_client, server_items, receiver, method_name, test_run_id, max_concurrency, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, on_progress: 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, mock_overrides: []) ⇒ Object
Fetch span data and execute a single replay item.
-
.release_db_branch_lease(http_client, lease) ⇒ Object
Delete the per-item Neon preview branch.
-
.run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, environment: nil, on_progress: nil) ⇒ Hash
Replay historical traces through a method and create a test run.
- .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.
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/bitfab/replay.rb', line 538 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.
577 578 579 580 581 582 583 584 585 586 587 588 589 590 |
# File 'lib/bitfab/replay.rb', line 577 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) 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 |
.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) ⇒ Object
Execute a single replay item: deserialize inputs, call method with replay context.
634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 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 |
# File 'lib/bitfab/replay.rb', line 634 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) args, kwargs = Serialize.deserialize_inputs(item) fn_result = nil fn_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 # Collects the root span's persistence threads (span uploads + trace # completion). Joined below so this item's trace is on the server # before run() calls complete_replay: otherwise the server's trace-ID # mapping races the uploads and the item's trace_id comes back nil. pending_persistence = [] 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:, pending_persistence:, db_branch_lease:, source_bitfab_trace_id: ) do # Reshape recorded inputs onto the current signature when an adapter is # supplied. Inside the rescue so a raising adapter surfaces on this # item's :error instead of crashing the run; args is reported on :input. 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 fn_result = if kwargs.empty? receiver.send(method_name, *args) else receiver.send(method_name, *args, **kwargs) end rescue => e fn_error = e. end # Wait for this item's trace (spans + completion) to be fully persisted # before the item resolves. Runs on the error path too: a raising # method still emits a root span whose trace must land before # complete_replay. Joins are bounded by the HTTP layer's own timeouts. pending_persistence.each(&:join) { input: args, result: fn_result, original_output: item["output"], error: fn_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.
611 612 613 614 615 616 617 |
# File 'lib/bitfab/replay.rb', line 611 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.
593 594 595 596 597 598 599 600 601 602 603 |
# File 'lib/bitfab/replay.rb', line 593 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 |
.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.
622 623 624 625 626 627 628 629 630 631 |
# File 'lib/bitfab/replay.rb', line 622 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
510 511 512 |
# File 'lib/bitfab/replay.rb', line 510 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.
506 507 508 |
# File 'lib/bitfab/replay.rb', line 506 def original_trace_id_of(server_item) server_item["originalTraceId"] || server_item["sourceTraceId"] 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, on_progress: nil, mock_overrides: []) ⇒ Object
Process all replay items, optionally in parallel using threads.
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 |
# File 'lib/bitfab/replay.rb', line 307 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, on_progress: nil, mock_overrides: []) concurrency = max_concurrency || server_items.length # Reports running totals once per item as it settles. In the parallel # path it runs from worker threads, so the mutex both makes the counter # updates safe and serializes the user's callback (never called # concurrently). A raising callback is swallowed: progress UI must never # crash the run. total = server_items.length progress_mutex = Mutex.new completed = 0 succeeded = 0 errored = 0 # Each event carries the single item that just settled so a progress UI # can render per-trace pass/fail as the run streams. The item's :trace_id # is nil at this stage: the server replay trace id isn't known until run() # writes it in after complete_replay, 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 # settled. source_trace_id/source_span_id are kept as deprecated aliases. report = lambda do |result, original_trace_id, original_span_id, test_run_id| return unless on_progress progress_mutex.synchronize do completed += 1 error = result[:error] error.nil? ? (succeeded += 1) : (errored += 1) begin on_progress.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:, duration_ms: result[:duration_ms], tokens: result[:tokens], model: result[:model], db_snapshot_ref: result[:db_snapshot_ref] } }) rescue => e warn "Bitfab: replay on_progress callback raised: #{e.}" end end end if concurrency <= 1 server_items.map do |item| result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs, include_db_branch_lease, 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 result = process_single_item(http_client, item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs, include_db_branch_lease, 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, 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).
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 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 |
# File 'lib/bitfab/replay.rb', line 399 def process_single_item(http_client, server_item, receiver, method_name, test_run_id, mock_strategy, adapt_inputs = nil, include_db_branch_lease = false, 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, or whose resolve failed server-side, arrive without a # lease (env.active? is false for those). lease = include_db_branch_lease ? server_item["dbBranchLease"] : nil span = http_client.get_external_span(original_span_id) 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:) 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: server_item["dbSnapshotRef"] ) rescue => e warn "Bitfab: replay item for span #{original_span_id} failed before execution: #{e.}" { input: [], result: nil, original_output: nil, 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: server_item["dbSnapshotRef"] } ensure release_db_branch_lease(http_client, lease) if lease 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.
516 517 518 519 520 521 522 523 |
# File 'lib/bitfab/replay.rb', line 516 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 |
.run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, environment: 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.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 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 |
# File 'lib/bitfab/replay.rb', line 130 def run(client, receiver, method_name, trace_function_key:, limit: nil, trace_ids: nil, name: nil, max_concurrency: 10, code_change_description: nil, code_change_files: nil, experiment_group_id: nil, dataset_id: nil, mock: "marked", adapt_inputs: nil, mock_override: nil, environment: 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 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 = !environment.nil? 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: ) test_run_id = replay_data["testRunId"] test_run_url = replay_data["testRunUrl"] 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, on_progress:, mock_overrides: resolved_overrides) else [] end # Every item joined its own trace-persistence threads (span uploads + # completion) in execute_item, so all replay traces are on the server # by now: no flush needed. 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 = http_client.complete_replay(test_run_id) 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] # Write the real server replay trace id in as it comes back; the item # held nil until now (the client correlation id is never surfaced). 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." raise "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 " \ "joined, so either the uploads failed or the replayed method is " \ "not traced (no root span was emitted)." 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: "#{client.service_url}#{test_run_url}" } # Persist the enriched result two ways so the Bitfab plugin never has to # parse the replay's stdout (which a dependency's logging can corrupt): # write it to BITFAB_REPLAY_RESULT_PATH when the plugin set that env var, # and stream a terminal "complete" progress event. The plugin prefers the # streamed event and falls back to the file. The event routes through # on_progress so only progress-reporting runs emit it. write_replay_result_file(result) if 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 |
.write_replay_result_file(result) ⇒ Object
293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/bitfab/replay.rb', line 293 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, "#{JSON.pretty_generate(result)}\n") rescue => e warn "Bitfab: failed to write replay result to BITFAB_REPLAY_RESULT_PATH (#{result_path}): #{e.}" end end |