bitfab 0.36.11 → 0.38.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0d23fea40d370a085bd028fbf7b605a6b66bc0732a1580683b3f027b5b2082e9
4
- data.tar.gz: a477ee5856fc6fb25a39a6088f820337cd2bcb327c3b028c9051d0b0f84edd5d
3
+ metadata.gz: 2816ce4e3bcd11bb1335ba7dac6b36376277d17075cf853d26c9befa231a758b
4
+ data.tar.gz: 7e923c2efb8dfd38edbdd592972a8f236a3fa81cdfabcbd46b8303ba4160b01e
5
5
  SHA512:
6
- metadata.gz: bb155e9ec03187b1f9f80a918b8df651bab41e181afcdd5c068c9645ab6b9c94884d7f8c41cab43bd50e878226e3d075962c02ac27e2b33ed2729e979b4e4464
7
- data.tar.gz: 1da28622e46e44b40f5296ed699ee13e897e7bd0a54177e32f3c629fb8004ea5f87a86546a37d608541ff771ba648fffb128bd613d7aefea300c8898038c7e47
6
+ metadata.gz: 15a2df82260f61733b90da3c110b195c92373c187c3eddce5faa041f44499bb008ec5a14cd27dab22b63e2973540010cc2e2f10fcc2dcd75481405d018a107f6
7
+ data.tar.gz: 13968f6a9f8d3c88ea2845d0d59e0cc1d31ffadc1054311addeedc1e4ba92d94aea504f0f5d11e9891fd0a233e92d285e635e5d8e85e46ebfdf553cdedc45005
data/exe/bitfab-replay ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bitfab"
5
+
6
+ exit Bitfab::ReplayCommand.main
data/lib/bitfab/client.rb CHANGED
@@ -91,7 +91,8 @@ module Bitfab
91
91
  # maximum: 5,000). Ignored when trace_ids or dataset_id is passed because
92
92
  # either source already determines how many traces replay. Supplying
93
93
  # trace_ids also emits a warning.
94
- # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100)
94
+ # @param trace_ids [Array<String>, nil] optional list of trace IDs to replay (max 100),
95
+ # mutually exclusive with dataset_id
95
96
  # @param max_concurrency [Integer, nil] max threads for parallel replay (default: 10)
96
97
  # @param code_change_description [String, nil] optional rationale for the
97
98
  # code change being tested in this replay (stored on the experiment).
@@ -105,7 +106,8 @@ module Bitfab
105
106
  # @param experiment_group_id [String, nil] optional UUID grouping multiple
106
107
  # replay runs into a single experiment batch
107
108
  # @param dataset_id [String, nil] optional UUID of the dataset this replay
108
- # runs against, stored on the resulting experiment for durable attribution
109
+ # runs against, mutually exclusive with trace_ids and stored on the
110
+ # resulting experiment for durable attribution
109
111
  # @param grader_ids [Array<String>, nil] optional UUIDs of graders attached
110
112
  # directly to this experiment, graded as the union with the dataset's
111
113
  # runnable graders at completion; each must be an active/live grader in the
@@ -424,7 +426,12 @@ module Bitfab
424
426
  # Deprecated wire alias, kept so this SDK still reports usage
425
427
  # against servers that predate the rename.
426
428
  source_trace_id: replay_ctx[:source_bitfab_trace_id],
427
- accessed: replay_ctx[:db_snapshot_accessed] == true
429
+ accessed: replay_ctx[:db_snapshot_accessed] == true,
430
+ # Echoed verbatim (camelCase inside) rather than re-cased into
431
+ # this record's snake_case: it is the server's own object
432
+ # coming back, and a translation layer here is one more thing
433
+ # to drift.
434
+ timings: replay_ctx[:db_branch_timings]
428
435
  }
429
436
  end
430
437
 
@@ -617,6 +624,9 @@ module Bitfab
617
624
  usage["source_trace_id"] = db_snapshot_usage[:original_trace_id]
618
625
  end
619
626
  usage["accessed"] = db_snapshot_usage[:accessed]
627
+ if db_snapshot_usage[:timings]
628
+ usage["timings"] = db_snapshot_usage[:timings]
629
+ end
620
630
  raw_trace["db_snapshot_usage"] = usage
621
631
  end
622
632
 
data/lib/bitfab/replay.rb CHANGED
@@ -74,7 +74,7 @@ module Bitfab
74
74
  # thread. Child threads and processes intentionally do not inherit it.
75
75
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
76
76
  mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
77
- db_branch_lease: nil, source_bitfab_trace_id: nil)
77
+ db_branch_lease: nil, db_branch_timings: nil, source_bitfab_trace_id: nil)
78
78
  previous = Thread.current.thread_variable_get(REPLAY_CONTEXT_KEY)
79
79
  ctx = {
80
80
  test_run_id:,
@@ -113,6 +113,10 @@ module Bitfab
113
113
  # accessed for free, and the flag would stop separating "branch was
114
114
  # used" from "branch was offered".
115
115
  ctx[:db_branch_lease] = db_branch_lease if db_branch_lease
116
+ # Kept off ReplayBranch: customer code reads that mid-replay to reach the
117
+ # branch, and provisioning latency is a property of the run, not of the
118
+ # connection. It rides the context only to be echoed on the completion.
119
+ ctx[:db_branch_timings] = db_branch_timings if db_branch_timings
116
120
  ctx[:source_bitfab_trace_id] = source_bitfab_trace_id if source_bitfab_trace_id
117
121
  Thread.current.thread_variable_set(REPLAY_CONTEXT_KEY, ctx)
118
122
  yield
@@ -256,11 +260,14 @@ module Bitfab
256
260
  raise ArgumentError, "trace_ids supports at most 100 trace IDs per replay (got #{trace_ids.length})."
257
261
  end
258
262
  end
263
+ if trace_ids && dataset_id
264
+ raise ArgumentError,
265
+ "trace_ids and dataset_id select different replay sources and cannot be used together."
266
+ end
259
267
  if limit && trace_ids
260
268
  warn "Bitfab: limit is ignored when trace_ids is passed: the explicit trace ID list already " \
261
269
  "determines how many traces replay."
262
270
  end
263
-
264
271
  # Reject a trace_function_key that contradicts the method's declared key.
265
272
  # replay() fetches historical traces by trace_function_key but records the
266
273
  # replayed spans under the method's own bitfab_span key (via send below),
@@ -765,9 +772,13 @@ module Bitfab
765
772
  trace_error: result[:trace_error],
766
773
  replay_error: result[:replay_error],
767
774
  duration_ms: result[:duration_ms],
775
+ original_duration_ms: result[:original_duration_ms],
776
+ original_tokens: result[:original_tokens],
777
+ original_model: result[:original_model],
768
778
  tokens: result[:tokens],
769
779
  model: result[:model],
770
- db_snapshot_ref: result[:db_snapshot_ref]
780
+ db_snapshot_ref: result[:db_snapshot_ref],
781
+ db_branch_timings: result[:db_branch_timings]
771
782
  }
772
783
  })
773
784
  rescue => e
@@ -836,6 +847,9 @@ module Bitfab
836
847
  # result that looks valid and is not. Fail the item instead, loudly.
837
848
  lease_error = include_db_branch_lease ? server_item["dbBranchLeaseError"] : nil
838
849
  db_snapshot_ref = server_item["dbSnapshotRef"]
850
+ # Reported whichever way the resolve went, so it is tracked separately
851
+ # from both the lease and the error rather than hanging off either.
852
+ db_branch_timings = include_db_branch_lease ? server_item["dbBranchTimings"] : nil
839
853
  if include_db_branch_lease && lease.nil? && lease_error.nil?
840
854
  begin
841
855
  resolved = http_client.resolve_db_branch_lease(test_run_id, original_trace_id, db_branch_settings)
@@ -850,6 +864,7 @@ module Bitfab
850
864
  lease = resolved["lease"]
851
865
  lease_error = resolved["leaseError"]
852
866
  db_snapshot_ref = resolved["dbSnapshotRef"] || db_snapshot_ref
867
+ db_branch_timings = resolved["timings"] || db_branch_timings
853
868
  end
854
869
  if lease_error
855
870
  raise DbBranchReplayError.new(
@@ -923,6 +938,7 @@ module Bitfab
923
938
  adapt_inputs:,
924
939
  adapt_ctx:,
925
940
  db_branch_lease: lease,
941
+ db_branch_timings:,
926
942
  source_bitfab_trace_id: original_trace_id,
927
943
  db_snapshot_ref:,
928
944
  http_client:
@@ -936,16 +952,20 @@ module Bitfab
936
952
  error: replay_item_error_message(e),
937
953
  trace_error: nil,
938
954
  replay_error: e,
939
- duration_ms: metrics&.dig(:duration_ms),
955
+ duration_ms: nil,
956
+ original_duration_ms: metrics&.dig(:original_duration_ms),
957
+ original_tokens: metrics&.dig(:original_tokens),
958
+ original_model: metrics&.dig(:original_model),
940
959
  tokens: metrics&.dig(:tokens),
941
- model: metrics&.dig(:model),
960
+ model: metrics&.dig(:original_model),
942
961
  trace_id: nil,
943
962
  original_trace_id:,
944
963
  original_span_id:,
945
964
  # Deprecated aliases for original_trace_id/original_span_id.
946
965
  source_trace_id: original_trace_id,
947
966
  source_span_id: original_span_id,
948
- db_snapshot_ref:
967
+ db_snapshot_ref:,
968
+ db_branch_timings:
949
969
  }
950
970
  ensure
951
971
  release_db_branch_lease(http_client, lease) if lease
@@ -1059,11 +1079,15 @@ module Bitfab
1059
1079
  # the ORIGINAL trace's tokens); the replayed run's tokens are filled in by
1060
1080
  # run() from the complete-replay response once spans are aggregated
1061
1081
  # server-side, and stay nil here and on older servers.
1082
+ # The original trace's measurements, falling back to the unprefixed keys a
1083
+ # server that predates the rename sends. :tokens stays nil here: it is the
1084
+ # REPLAYED run's, filled in from the complete-replay response.
1062
1085
  def extract_server_item_metrics(server_item)
1063
1086
  {
1064
- duration_ms: server_item["durationMs"],
1065
- tokens: nil,
1066
- model: server_item["model"]
1087
+ original_duration_ms: server_item["originalDurationMs"] || server_item["durationMs"],
1088
+ original_tokens: server_item["originalTokens"] || server_item["tokens"],
1089
+ original_model: server_item["originalModel"] || server_item["model"],
1090
+ tokens: nil
1067
1091
  }
1068
1092
  end
1069
1093
 
@@ -1144,13 +1168,15 @@ module Bitfab
1144
1168
  # Execute a single replay item: deserialize inputs, call method with replay context.
1145
1169
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
1146
1170
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1147
- fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil,
1148
- db_snapshot_ref: nil, http_client: nil)
1171
+ fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, db_branch_timings: nil,
1172
+ source_bitfab_trace_id: nil, db_snapshot_ref: nil, http_client: nil)
1149
1173
  args, kwargs = Serialize.deserialize_inputs(item)
1150
1174
 
1151
1175
  fn_result = nil
1152
1176
  fn_error = nil
1153
1177
  replay_error = nil
1178
+ replay_duration_ms = nil
1179
+ replay_started = nil
1154
1180
  # Client-side correlation id that tags this item's replay spans so the
1155
1181
  # server can echo back the row id it minted (resolved in run()'s
1156
1182
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
@@ -1172,6 +1198,7 @@ module Bitfab
1172
1198
  mock_overrides:,
1173
1199
  fetch_span_output:,
1174
1200
  db_branch_lease:,
1201
+ db_branch_timings:,
1175
1202
  source_bitfab_trace_id:
1176
1203
  ) do
1177
1204
  begin
@@ -1190,17 +1217,26 @@ module Bitfab
1190
1217
  end
1191
1218
  if replay_error.nil?
1192
1219
  begin
1220
+ replay_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1193
1221
  fn_result = if kwargs.empty?
1194
1222
  receiver.send(method_name, *args)
1195
1223
  else
1196
1224
  receiver.send(method_name, *args, **kwargs)
1197
1225
  end
1226
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1198
1227
  rescue => e
1228
+ # The method ran and raised, so it still has a duration.
1229
+ if replay_started
1230
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1231
+ end
1199
1232
  fn_error = e
1200
1233
  end
1201
1234
  end
1202
1235
  end
1203
1236
  rescue => e
1237
+ if replay_started
1238
+ replay_duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - replay_started) * 1000).round
1239
+ end
1204
1240
  replay_error = e
1205
1241
  end
1206
1242
 
@@ -1212,9 +1248,12 @@ module Bitfab
1212
1248
  error: item_error&.message,
1213
1249
  trace_error: fn_error,
1214
1250
  replay_error:,
1215
- duration_ms: metrics[:duration_ms],
1251
+ duration_ms: replay_duration_ms,
1252
+ original_duration_ms: metrics[:original_duration_ms],
1253
+ original_tokens: metrics[:original_tokens],
1254
+ original_model: metrics[:original_model],
1216
1255
  tokens: metrics[:tokens],
1217
- model: metrics[:model],
1256
+ model: metrics[:original_model],
1218
1257
  # Written in by run() from the complete-replay response once the server
1219
1258
  # has minted this replay trace's row. Nil until then: the client-side
1220
1259
  # correlation id (below) is never surfaced as the public :trace_id.
@@ -1225,7 +1264,8 @@ module Bitfab
1225
1264
  # Deprecated aliases for original_trace_id/original_span_id.
1226
1265
  source_trace_id: source_bitfab_trace_id,
1227
1266
  source_span_id: input_source_span_id,
1228
- db_snapshot_ref:
1267
+ db_snapshot_ref:,
1268
+ db_branch_timings:
1229
1269
  }
1230
1270
  end
1231
1271
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Bitfab
6
+ # Installed command that loads a project-owned registry module.
7
+ module ReplayCommand
8
+ USAGE = "Usage: bitfab-replay --registry <path> <pipeline> [replay options]"
9
+ HELP = "#{USAGE}\nRun bitfab-replay --registry <path> --help to list replay options."
10
+
11
+ module_function
12
+
13
+ def run(argv: ARGV, stdout: $stdout, stderr: $stderr, registry_loader: nil)
14
+ registry_path, replay_argv = parse(argv)
15
+ loader = registry_loader || method(:load_registry)
16
+ registry = loader.call(registry_path)
17
+ ReplayCli.run(registry, argv: replay_argv, stdout:, stderr:)
18
+ end
19
+
20
+ def main(argv: ARGV, stdout: $stdout, stderr: $stderr)
21
+ if (argv.include?("--help") || argv.include?("-h")) && !argv.include?("--registry")
22
+ stdout.puts HELP
23
+ return 0
24
+ end
25
+ run(argv:, stdout:, stderr:)
26
+ 0
27
+ rescue => error
28
+ stderr.puts error.message
29
+ 1
30
+ end
31
+
32
+ def parse(argv)
33
+ values = argv.dup
34
+ registry_index = values.index("--registry")
35
+ registry_path = values[registry_index + 1] if registry_index
36
+ unless registry_path && !registry_path.start_with?("--")
37
+ raise OptionParser::MissingArgument, USAGE
38
+ end
39
+
40
+ values.slice!(registry_index, 2)
41
+ raise OptionParser::MissingArgument, USAGE if values.empty?
42
+
43
+ [File.expand_path(registry_path), values]
44
+ end
45
+
46
+ def load_registry(path)
47
+ container = Module.new
48
+ container.module_eval(File.read(path), path)
49
+ registry = container.const_get(:REGISTRY, false) if container.const_defined?(:REGISTRY, false)
50
+ unless registry.is_a?(ReplayRegistry)
51
+ raise ArgumentError,
52
+ "Replay registry '#{path}' must define REGISTRY as a Bitfab::ReplayRegistry."
53
+ end
54
+
55
+ registry
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module Bitfab
7
+ # One project function exposed through the SDK-owned replay command.
8
+ ReplayRegistration = Data.define(
9
+ :client,
10
+ :receiver,
11
+ :method_name,
12
+ :trace_function_key,
13
+ :options,
14
+ :options_factory
15
+ )
16
+
17
+ ReplayRegistryContext = Data.define(:params)
18
+
19
+ # Project-owned registry of production replay roots.
20
+ class ReplayRegistry
21
+ OPTION_NAMES = %i[
22
+ limit trace_ids name max_concurrency code_change_description
23
+ code_change_files experiment_group_id dataset_id grader_ids mock
24
+ mock_override adapt_inputs db_branch
25
+ ].freeze
26
+
27
+ def initialize
28
+ @entries = {}
29
+ end
30
+
31
+ def names
32
+ @entries.keys.freeze
33
+ end
34
+
35
+ # Register the exact traced receiver method production invokes.
36
+ #
37
+ # A +bitfab_span+ method carries its trace function key automatically.
38
+ # Handler-instrumented or otherwise plain methods must pass
39
+ # +trace_function_key:+ explicitly. Pass executable per-function replay
40
+ # behavior such as +mock_override:+ and +adapt_inputs:+ as keyword options;
41
+ # the installed command forwards them to +Bitfab::Client#replay+.
42
+ def register(name, receiver, method_name, client: nil, trace_function_key: nil, options_factory: nil, **options)
43
+ raise ArgumentError, "Replay registry names cannot be empty." if name.to_s.empty?
44
+ raise ArgumentError, "Replay registry already contains '#{name}'." if @entries.key?(name.to_s)
45
+ if options_factory && !options_factory.respond_to?(:call)
46
+ raise ArgumentError, "Replay registry options_factory must be callable."
47
+ end
48
+
49
+ key = trace_function_key || Traceable.trace_function_key_for(receiver, method_name)
50
+ unless key
51
+ raise ArgumentError,
52
+ "Replay registry entry uses a plain method. Set trace_function_key: " \
53
+ "to the key its production handler records."
54
+ end
55
+ validate_options(options)
56
+
57
+ @entries[name.to_s] = ReplayRegistration.new(
58
+ client: client || Bitfab.client,
59
+ receiver:,
60
+ method_name: method_name.to_sym,
61
+ trace_function_key: key,
62
+ options: options.freeze,
63
+ options_factory:
64
+ )
65
+ self
66
+ end
67
+
68
+ def fetch(name)
69
+ @entries.fetch(name)
70
+ end
71
+
72
+ def validate_options(options)
73
+ unknown = options.keys - OPTION_NAMES
74
+ return if unknown.empty?
75
+
76
+ raise ArgumentError,
77
+ "Unknown replay registry option(s): #{unknown.sort.join(", ")}. " \
78
+ "Allowed options: #{OPTION_NAMES.sort.join(", ")}."
79
+ end
80
+ end
81
+
82
+ # Argument parsing and output for the SDK-owned replay command.
83
+ module ReplayCli
84
+ module_function
85
+
86
+ def run(registry, argv: ARGV, stdout: $stdout, stderr: $stderr)
87
+ args = parse(registry, argv.dup)
88
+ registration = registry.fetch(args.fetch(:pipeline))
89
+ options = registration.options.dup
90
+ params = load_parameters(args[:params], args.fetch(:param, []))
91
+ if registration.options_factory
92
+ dynamic_options = registration.options_factory.call(ReplayRegistryContext.new(params:))
93
+ unless dynamic_options.is_a?(Hash)
94
+ raise ArgumentError, "Replay registry options_factory must return a Hash."
95
+ end
96
+ registry.validate_options(dynamic_options)
97
+ options.merge!(dynamic_options)
98
+ end
99
+
100
+ if options[:trace_ids] && options[:dataset_id]
101
+ raise ArgumentError,
102
+ "Replay registry options trace_ids and dataset_id select different sources and cannot be used together."
103
+ end
104
+
105
+ if args[:trace_ids]
106
+ options.delete(:limit)
107
+ options.delete(:dataset_id)
108
+ options[:trace_ids] = args[:trace_ids]
109
+ elsif args[:dataset_id]
110
+ options.delete(:trace_ids)
111
+ options.delete(:limit)
112
+ elsif args[:limit]
113
+ options.delete(:trace_ids)
114
+ options.delete(:dataset_id)
115
+ options[:limit] = args[:limit]
116
+ elsif options[:trace_ids] || options[:dataset_id]
117
+ options.delete(:limit)
118
+ else
119
+ options[:limit] = options.fetch(:limit, 10)
120
+ end
121
+
122
+ %i[name max_concurrency experiment_group_id dataset_id grader_ids mock].each do |key|
123
+ options[key] = args[key] unless args[key].nil?
124
+ end
125
+
126
+ unless args[:db_branch].nil?
127
+ if args[:db_branch]
128
+ configured = options[:db_branch]
129
+ options[:db_branch] = (configured.nil? || configured == false) ? true : configured
130
+ else
131
+ options[:db_branch] = false
132
+ end
133
+ end
134
+
135
+ if args[:code_change]
136
+ code_change = load_code_change(args[:code_change])
137
+ options[:code_change_description] = code_change.fetch("description")
138
+ options[:code_change_files] = code_change.fetch("files")
139
+ elsif args[:no_code_change]
140
+ options[:code_change_description] = nil
141
+ options[:code_change_files] = nil
142
+ end
143
+
144
+ reporter = Bitfab.method(:report_replay_progress)
145
+ options[:on_item_start] = reporter
146
+ options[:on_item_finish] = reporter
147
+
148
+ count = options[:trace_ids]&.length || options[:limit] || "dataset"
149
+ stderr.puts "[replay] Replaying #{count} traces from \"#{registration.trace_function_key}\"..."
150
+
151
+ result = begin
152
+ registration.client.replay(
153
+ registration.receiver,
154
+ registration.method_name,
155
+ trace_function_key: registration.trace_function_key,
156
+ **options
157
+ )
158
+ rescue ReplayError => error
159
+ error.items.each do |item|
160
+ item_error = item[:trace_error] || item[:replay_error] || item[:error]
161
+ stderr.puts "#{item[:original_trace_id]}: #{item_error}"
162
+ end
163
+ raise
164
+ end
165
+
166
+ render_summary(args.fetch(:pipeline), result, stderr)
167
+ stdout.puts Bitfab.serialize_replay_result(result)
168
+ result
169
+ end
170
+
171
+ def parse(registry, argv)
172
+ if argv.include?("--db-branch") && argv.include?("--no-db-branch")
173
+ raise OptionParser::InvalidArgument, "--db-branch and --no-db-branch cannot be used together"
174
+ end
175
+ args = {}
176
+ parser = OptionParser.new do |options|
177
+ options.banner = "Usage: bitfab-replay <#{registry.names.join("|")}> [options]"
178
+ options.on("--limit N", Integer) { |value| args[:limit] = positive_integer("--limit", value) }
179
+ options.on("--trace-ids IDS") { |value| args[:trace_ids] = comma_separated("--trace-ids", value) }
180
+ options.on("--name NAME") { |value| args[:name] = value }
181
+ options.on("--concurrency N", Integer) do |value|
182
+ args[:max_concurrency] = positive_integer("--concurrency", value)
183
+ end
184
+ options.on("--max-concurrency N", Integer) do |value|
185
+ args[:max_concurrency] = positive_integer("--max-concurrency", value)
186
+ end
187
+ options.on("--code-change PATH") { |value| args[:code_change] = value }
188
+ options.on("--experiment-group-id UUID") { |value| args[:experiment_group_id] = value }
189
+ options.on("--dataset-id UUID") { |value| args[:dataset_id] = value }
190
+ options.on("--grader-ids IDS") { |value| args[:grader_ids] = comma_separated("--grader-ids", value) }
191
+ options.on("--mock STRATEGY", %w[none all marked]) { |value| args[:mock] = value }
192
+ options.on("--db-branch") { args[:db_branch] = true }
193
+ options.on("--no-db-branch") { args[:db_branch] = false }
194
+ options.on("--no-code-change") { args[:no_code_change] = true }
195
+ options.on("--params PATH") { |value| args[:params] = value }
196
+ options.on("--param NAME=VALUE") { |value| (args[:param] ||= []) << value }
197
+ end
198
+ parser.parse!(argv)
199
+ if args[:trace_ids] && args[:dataset_id]
200
+ raise OptionParser::InvalidArgument,
201
+ "--trace-ids and --dataset-id select different replay sources and cannot be used together"
202
+ end
203
+ if args[:code_change] && args[:no_code_change]
204
+ raise OptionParser::InvalidArgument, "--code-change and --no-code-change cannot be used together"
205
+ end
206
+ pipeline = argv.shift
207
+ raise OptionParser::MissingArgument, parser.banner unless pipeline && registry.names.include?(pipeline)
208
+ raise OptionParser::InvalidArgument, "unexpected arguments: #{argv.join(" ")}" unless argv.empty?
209
+
210
+ args.merge(pipeline:)
211
+ end
212
+
213
+ def positive_integer(flag, value)
214
+ raise OptionParser::InvalidArgument, "#{flag} must be a positive integer" if value < 1
215
+
216
+ value
217
+ end
218
+
219
+ def comma_separated(flag, value)
220
+ values = value.split(",").map(&:strip).reject(&:empty?)
221
+ raise OptionParser::InvalidArgument, "#{flag} must contain at least one value" if values.empty?
222
+
223
+ values
224
+ end
225
+
226
+ def load_code_change(path)
227
+ value = JSON.parse(File.read(path))
228
+ unless value.is_a?(Hash) && value["description"].is_a?(String) && value["files"].is_a?(Array)
229
+ raise ArgumentError, "Invalid --code-change file '#{path}': expected { description, files }."
230
+ end
231
+
232
+ value
233
+ end
234
+
235
+ def parse_parameter(raw)
236
+ key, separator, value = raw.partition("=")
237
+ key = key.strip
238
+ if separator.empty? || key.empty?
239
+ raise OptionParser::InvalidArgument,
240
+ "Invalid --param '#{raw}': expected a non-empty name=value pair"
241
+ end
242
+
243
+ parsed = begin
244
+ JSON.parse(value)
245
+ rescue JSON::ParserError
246
+ value
247
+ end
248
+ [key, parsed]
249
+ end
250
+
251
+ def load_parameters(path, raw_parameters)
252
+ params = {}
253
+ if path
254
+ value = JSON.parse(File.read(path))
255
+ unless value.is_a?(Hash)
256
+ raise ArgumentError, "Invalid --params file '#{path}': expected a JSON object."
257
+ end
258
+ params.merge!(value)
259
+ end
260
+ raw_parameters.each do |raw|
261
+ key, value = parse_parameter(raw)
262
+ params[key] = value
263
+ end
264
+ params.freeze
265
+ end
266
+
267
+ def render_summary(pipeline, result, stderr)
268
+ same = 0
269
+ changed = 0
270
+ errors = 0
271
+ result[:items].each do |item|
272
+ if item[:error]
273
+ errors += 1
274
+ elsif item[:result] == item[:original_output]
275
+ same += 1
276
+ else
277
+ changed += 1
278
+ end
279
+ end
280
+
281
+ stderr.puts "\n─── Summary ───"
282
+ stderr.puts " Pipeline: #{pipeline}"
283
+ stderr.puts " Replayed: #{result[:items].length}"
284
+ stderr.puts " Same: #{same}"
285
+ stderr.puts " Changed: #{changed}"
286
+ stderr.puts " Errors: #{errors}" if errors > 0
287
+ stderr.puts "\n #{result[:test_run_url]}"
288
+ end
289
+ end
290
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.11"
4
+ VERSION = "0.38.1"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -15,6 +15,8 @@ require_relative "bitfab/replay"
15
15
  require_relative "bitfab/replay_branch"
16
16
  require_relative "bitfab/client"
17
17
  require_relative "bitfab/traceable"
18
+ require_relative "bitfab/replay_registry"
19
+ require_relative "bitfab/replay_cli"
18
20
 
19
21
  module Bitfab
20
22
  # No-op span handle returned when outside a span context.
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.36.11
4
+ version: 0.38.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -131,11 +131,13 @@ description: Client library for sending function execution spans to the Bitfab A
131
131
  Provides automatic tracing with nested span support.
132
132
  email:
133
133
  - team@goharvest.ai
134
- executables: []
134
+ executables:
135
+ - bitfab-replay
135
136
  extensions: []
136
137
  extra_rdoc_files: []
137
138
  files:
138
139
  - README.md
140
+ - exe/bitfab-replay
139
141
  - lib/bitfab.rb
140
142
  - lib/bitfab/client.rb
141
143
  - lib/bitfab/compress.rb
@@ -147,6 +149,8 @@ files:
147
149
  - lib/bitfab/payload_budget.rb
148
150
  - lib/bitfab/replay.rb
149
151
  - lib/bitfab/replay_branch.rb
152
+ - lib/bitfab/replay_cli.rb
153
+ - lib/bitfab/replay_registry.rb
150
154
  - lib/bitfab/serialize.rb
151
155
  - lib/bitfab/span_context.rb
152
156
  - lib/bitfab/traceable.rb