bitfab 0.33.5 → 0.34.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.
data/lib/bitfab/replay.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "constants"
9
9
  require_relative "mock_override"
10
10
  require_relative "serialize"
11
11
  require_relative "traceable"
12
+ require_relative "transport"
12
13
 
13
14
  module Bitfab
14
15
  # Replay mock strategies. Mirrors the Python and TypeScript SDKs.
@@ -29,12 +30,8 @@ module Bitfab
29
30
 
30
31
  # Execute a block with replay context set on the current thread.
31
32
  # The context is automatically cleared when the block completes.
32
- #
33
- # pending_persistence, when given, collects the root span's persistence
34
- # threads (span uploads + trace completion) so the replay runner can join
35
- # them before complete_replay builds the trace-ID mapping.
36
33
  def with_context(test_run_id:, input_source_span_id: nil, input_source_trace_id: nil, trace_id: nil,
37
- mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil, pending_persistence: nil,
34
+ mock_tree: nil, mock_strategy: nil, mock_overrides: nil, fetch_span_output: nil,
38
35
  db_branch_lease: nil, source_bitfab_trace_id: nil)
39
36
  previous = Thread.current[REPLAY_CONTEXT_KEY]
40
37
  ctx = {
@@ -43,7 +40,6 @@ module Bitfab
43
40
  input_source_trace_id:,
44
41
  trace_id:
45
42
  }
46
- ctx[:pending_persistence] = pending_persistence if pending_persistence
47
43
  if mock_tree
48
44
  ctx[:mock_tree] = mock_tree
49
45
  ctx[:mock_strategy] = mock_strategy || "marked"
@@ -82,6 +78,8 @@ module Bitfab
82
78
  # Replay historical traces through a traced method and create a test run.
83
79
  module Replay
84
80
  CODE_CHANGE_UNSET = Object.new.freeze
81
+ PERSISTENCE_TIMEOUT_SECONDS = 30.0
82
+ PERSISTENCE_POLL_SECONDS = 0.1
85
83
 
86
84
  module_function
87
85
 
@@ -233,11 +231,15 @@ module Bitfab
233
231
  []
234
232
  end
235
233
 
236
- # Every item joined its own trace-persistence threads (span uploads +
237
- # completion) in execute_item, so all replay traces are on the server
238
- # by now: no flush needed. complete_replay finalizes the run and returns
239
- # the token/diagnostic mapping; its failures propagate loudly because a
240
- # run that never completed can't be finalized.
234
+ # Spans and completions ride a batched transport, so the run is only safe
235
+ # to finalize once the server confirms every replay trace it queued: the
236
+ # trace-ID mapping complete_replay builds would otherwise race the
237
+ # in-flight batches and hand back nil for every item.
238
+ wait_for_replay_persistence(http_client, test_run_id, result_items.map { |item| item[:_sdk_trace_id] })
239
+
240
+ # complete_replay finalizes the run and returns the token/diagnostic
241
+ # mapping; its failures propagate loudly because a run that never
242
+ # completed can't be finalized.
241
243
  complete_response = http_client.complete_replay(test_run_id)
242
244
  trace_id_map = complete_response&.dig("traceIds")
243
245
  # Per-replay-trace token usage keyed by server trace id: the REPLAYED
@@ -277,7 +279,7 @@ module Bitfab
277
279
  raise "Replay completed but the server has no persisted trace for " \
278
280
  "any of the #{completed_count} completed item(s) " \
279
281
  "(test_run_id #{test_run_id}).#{server_count} Trace uploads were " \
280
- "joined, so either the uploads failed or the replayed method is " \
282
+ "flushed, so either the uploads failed or the replayed method is " \
281
283
  "not traced (no root span was emitted)."
282
284
  end
283
285
  # SOME completed items missing: per-item upload failure. Warn, but
@@ -876,6 +878,40 @@ module Bitfab
876
878
  }
877
879
  end
878
880
 
881
+ # Block until the server has persisted every replay trace this run queued.
882
+ #
883
+ # Flushing only proves the batches left the process. The server writes a
884
+ # trace's spans and completion independently, so the barrier also polls
885
+ # replay status with the span count each trace owes: a trace is ready only
886
+ # once its completion and all of its spans have landed.
887
+ def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids)
888
+ expected_span_counts = Transport.take_replay_span_counts(sdk_trace_ids.compact)
889
+ return if expected_span_counts.empty?
890
+
891
+ unless Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS)
892
+ raise "Replay traces could not be flushed before the delivery deadline " \
893
+ "(test_run_id #{test_run_id})."
894
+ end
895
+
896
+ deadline = Otel.monotonic_now + PERSISTENCE_TIMEOUT_SECONDS
897
+ missing = expected_span_counts.keys
898
+
899
+ while missing.any?
900
+ status = http_client.get_replay_status(test_run_id, expected_span_counts)
901
+ ready = status["traceIds"]
902
+ ready = {} unless ready.is_a?(Hash)
903
+ missing = expected_span_counts.keys - ready.keys
904
+ return if missing.empty?
905
+ break if Otel.monotonic_now >= deadline
906
+
907
+ sleep((deadline - Otel.monotonic_now).clamp(0, PERSISTENCE_POLL_SECONDS))
908
+ end
909
+
910
+ raise "Replay traces were not fully persisted before the delivery deadline " \
911
+ "(test_run_id #{test_run_id}, missing #{missing.length} of " \
912
+ "#{expected_span_counts.length} trace(s))."
913
+ end
914
+
879
915
  # Normalize a complete-replay tokens hash (string-keyed JSON) into the
880
916
  # symbol-keyed shape the replay item exposes. Nil when the server reported
881
917
  # no token data for this trace.
@@ -904,11 +940,6 @@ module Bitfab
904
940
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
905
941
  # surfaced as the public :trace_id.
906
942
  sdk_trace_id = SecureRandom.uuid
907
- # Collects the root span's persistence threads (span uploads + trace
908
- # completion). Joined below so this item's trace is on the server
909
- # before run() calls complete_replay: otherwise the server's trace-ID
910
- # mapping races the uploads and the item's trace_id comes back nil.
911
- pending_persistence = []
912
943
 
913
944
  ReplayContext.with_context(
914
945
  test_run_id:,
@@ -919,7 +950,6 @@ module Bitfab
919
950
  mock_strategy:,
920
951
  mock_overrides:,
921
952
  fetch_span_output:,
922
- pending_persistence:,
923
953
  db_branch_lease:,
924
954
  source_bitfab_trace_id:
925
955
  ) do
@@ -945,12 +975,6 @@ module Bitfab
945
975
  fn_error = e.message
946
976
  end
947
977
 
948
- # Wait for this item's trace (spans + completion) to be fully persisted
949
- # before the item resolves. Runs on the error path too: a raising
950
- # method still emits a root span whose trace must land before
951
- # complete_replay. Joins are bounded by the HTTP layer's own timeouts.
952
- pending_persistence.each(&:join)
953
-
954
978
  {
955
979
  input: args,
956
980
  result: fn_result,
@@ -236,5 +236,45 @@ module Bitfab
236
236
  [[raw_input], {}]
237
237
  end
238
238
  end
239
+
240
+ # JSON-encode a request or carrier body without ever raising on a stray
241
+ # value.
242
+ #
243
+ # Upstream serialization (serialize_value) should already have flattened
244
+ # user data. This is the boundary backstop: if anything non-serializable
245
+ # still slips through, it is run through serialize_value (which never
246
+ # raises and stubs strays) instead of letting JSON.generate raise and drop
247
+ # the whole span/trace silently. A degraded payload warns loudly so the
248
+ # trace isn't quietly left incomplete or not replayable.
249
+ def safe_generate(payload)
250
+ JSON.generate(payload)
251
+ rescue => e
252
+ Bitfab.warn_once(
253
+ "request-body-stubbed",
254
+ "a request body held a non-serializable value (#{e.message}); it was " \
255
+ "stubbed so the span still sends, but the trace may be incomplete or " \
256
+ "not replayable. Capture a JSON-safe projection of this input to make " \
257
+ "it replayable."
258
+ )
259
+
260
+ begin
261
+ JSON.generate(sanitize_payload(payload))
262
+ rescue
263
+ # Truly pathological. Still never drop silently: send a marker body.
264
+ JSON.generate({"error" => "payload_serialize_failed"})
265
+ end
266
+ end
267
+
268
+ # Serialize each top-level value so a bad or oversize value is stubbed in
269
+ # place while the payload keeps its object shape. Running serialize_value on
270
+ # the whole payload could collapse the entire body to a single stub string
271
+ # (oversize/cyclic), sending a JSON string instead of a span object.
272
+ def sanitize_payload(payload)
273
+ return {"error" => "payload_serialize_failed"} unless payload.is_a?(Hash)
274
+
275
+ payload.each_with_object({}) do |(k, v), acc|
276
+ acc[k.to_s] = serialize_value(v)
277
+ end
278
+ end
239
279
  end
240
280
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "otel"
4
+
5
+ module Bitfab
6
+ # Framework-neutral seam between span/trace sending and the delivery
7
+ # mechanism, so callers never depend on OpenTelemetry directly.
8
+ module Transport
9
+ class << self
10
+ def create_trace_transport(direct_sender:)
11
+ Otel.create_transport(direct_sender:)
12
+ end
13
+
14
+ def flush_trace_transports(timeout = 30.0)
15
+ Otel.flush_transports(timeout)
16
+ end
17
+
18
+ def shutdown_trace_transports(timeout = 30.0)
19
+ Otel.shutdown_transports(timeout)
20
+ end
21
+
22
+ def take_replay_span_counts(trace_ids)
23
+ Otel.take_replay_span_counts(trace_ids)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.33.5"
4
+ VERSION = "0.34.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.33.5
4
+ version: 0.34.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -23,6 +23,26 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: opentelemetry-sdk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '2'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '1.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '2'
26
46
  - !ruby/object:Gem::Dependency
27
47
  name: rake
28
48
  requirement: !ruby/object:Gem::Requirement
@@ -122,11 +142,13 @@ files:
122
142
  - lib/bitfab/db_snapshot.rb
123
143
  - lib/bitfab/http_client.rb
124
144
  - lib/bitfab/mock_override.rb
145
+ - lib/bitfab/otel.rb
125
146
  - lib/bitfab/replay.rb
126
147
  - lib/bitfab/replay_branch.rb
127
148
  - lib/bitfab/serialize.rb
128
149
  - lib/bitfab/span_context.rb
129
150
  - lib/bitfab/traceable.rb
151
+ - lib/bitfab/transport.rb
130
152
  - lib/bitfab/version.rb
131
153
  - lib/bitfab/warn_once.rb
132
154
  homepage: https://bitfab.ai