bitfab 0.14.0 → 0.16.0

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