bitfab 0.33.4 → 0.33.6

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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry-exporter-otlp"
4
+
5
+ module Bitfab
6
+ module Otel
7
+ # OTLP/HTTP protobuf delivery to a customer's Collector.
8
+ class CollectorExporter < OpenTelemetry::Exporter::OTLP::Exporter
9
+ def self.traces_endpoint(endpoint)
10
+ base = endpoint.chomp("/")
11
+ base.end_with?("/v1/traces") ? base : "#{base}/v1/traces"
12
+ end
13
+
14
+ def initialize(api_key:, **options)
15
+ super(**options)
16
+ @api_key = api_key
17
+ end
18
+
19
+ # The OTLP exporter reads headers once at construction, while a Bitfab key
20
+ # is resolved at request time (an env var may load after the client is built).
21
+ def export(span_data, timeout: nil)
22
+ key = @api_key.respond_to?(:call) ? @api_key.call : @api_key
23
+ @headers = @headers.merge("Authorization" => "Bearer #{key}")
24
+ super
25
+ end
26
+ end
27
+ end
28
+ end
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
 
@@ -127,10 +125,10 @@ module Bitfab
127
125
  # source_trace_id/source_span_id aliases), and returns [new_args, new_kwargs].
128
126
  # Runs per item inside the same rescue as the method, so a raising adapter
129
127
  # sets that item's :error rather than crashing the run.
130
- # @param db_branch [Hash, nil] passing it at all turns database branching
131
- # on, so +{}+ enables it with the mirror project's own sizing. Keys
132
- # +:min_cu+/+:max_cu+ are the branch compute's autoscaling floor and
133
- # ceiling in Neon Compute Units, and +:warmup_sql+ warms the branch's
128
+ # @param db_branch [Boolean, Hash, nil] +true+ branches with the mirror
129
+ # project's own sizing; a Hash tunes it and +false+/+nil+ leave branching
130
+ # off. Keys +:min_cu+/+:max_cu+ are the branch compute's autoscaling floor
131
+ # and ceiling in Neon Compute Units, and +:warmup_sql+ warms the branch's
134
132
  # cache before the replayed method sees it. Read the resolved branch
135
133
  # inside the method with +Bitfab.current_replay_branch+.
136
134
  # @param on_progress [#call, nil] optional callback invoked once per item as
@@ -192,7 +190,7 @@ module Bitfab
192
190
  # the count), so it's omitted from the request entirely.
193
191
  effective_limit = trace_ids ? nil : (limit || 5)
194
192
 
195
- include_db_branch_lease = !db_branch.nil?
193
+ include_db_branch_lease = db_branch_enabled?(db_branch)
196
194
 
197
195
  # code_change_files controls capture: omitted auto-captures, an array
198
196
  # wins, and explicit nil opts out. Preserve a caller-supplied description
@@ -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
@@ -335,16 +337,26 @@ module Bitfab
335
337
  CC_MAX_FILE_BYTES = 500_000
336
338
  CC_MAX_TOTAL_BYTES = 2_000_000
337
339
 
340
+ # Whether the caller asked for database branching. +true+ and a Hash both
341
+ # turn it on; +false+ and +nil+ leave it off. Kept separate from
342
+ # +db_branch_settings+ because +db_branch: true+ enables branching while
343
+ # contributing no settings, so the presence of settings can't stand in for
344
+ # the switch.
345
+ def db_branch_enabled?(db_branch)
346
+ !db_branch.nil? && db_branch != false
347
+ end
348
+
338
349
  # Convert the caller's +db_branch+ options to the wire shape, or nil when
339
350
  # nothing was set. Dropping the key entirely keeps the request identical to
340
- # what older SDKs send.
351
+ # what older SDKs send. Booleans carry no settings: they only move the
352
+ # switch.
341
353
  #
342
354
  # Keys: :min_cu and :max_cu are the branch compute's autoscaling floor and
343
355
  # ceiling in Neon Compute Units (equal values pin the size, keeping items
344
356
  # comparable); :warmup_sql is appended to the branch's readiness check so it
345
357
  # warms the cache before the replayed function sees the lease.
346
358
  def db_branch_settings(db_branch)
347
- return nil if db_branch.nil? || db_branch.empty?
359
+ return nil if db_branch.nil? || db_branch == true || db_branch == false || db_branch.empty?
348
360
 
349
361
  # Symbol or string keys, matching normalize_code_change_files: a hash
350
362
  # assembled from JSON or YAML would otherwise be read as empty, leaving
@@ -866,6 +878,40 @@ module Bitfab
866
878
  }
867
879
  end
868
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
+
869
915
  # Normalize a complete-replay tokens hash (string-keyed JSON) into the
870
916
  # symbol-keyed shape the replay item exposes. Nil when the server reported
871
917
  # no token data for this trace.
@@ -894,11 +940,6 @@ module Bitfab
894
940
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
895
941
  # surfaced as the public :trace_id.
896
942
  sdk_trace_id = SecureRandom.uuid
897
- # Collects the root span's persistence threads (span uploads + trace
898
- # completion). Joined below so this item's trace is on the server
899
- # before run() calls complete_replay: otherwise the server's trace-ID
900
- # mapping races the uploads and the item's trace_id comes back nil.
901
- pending_persistence = []
902
943
 
903
944
  ReplayContext.with_context(
904
945
  test_run_id:,
@@ -909,7 +950,6 @@ module Bitfab
909
950
  mock_strategy:,
910
951
  mock_overrides:,
911
952
  fetch_span_output:,
912
- pending_persistence:,
913
953
  db_branch_lease:,
914
954
  source_bitfab_trace_id:
915
955
  ) do
@@ -935,12 +975,6 @@ module Bitfab
935
975
  fn_error = e.message
936
976
  end
937
977
 
938
- # Wait for this item's trace (spans + completion) to be fully persisted
939
- # before the item resolves. Runs on the error path too: a raising
940
- # method still emits a root span whose trace must land before
941
- # complete_replay. Joins are bounded by the HTTP layer's own timeouts.
942
- pending_persistence.each(&:join)
943
-
944
978
  {
945
979
  input: args,
946
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(api_key:, direct_sender:)
11
+ Otel.create_transport(api_key:, 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.4"
4
+ VERSION = "0.33.6"
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.4
4
+ version: 0.33.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -23,6 +23,40 @@ 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'
46
+ - !ruby/object:Gem::Dependency
47
+ name: opentelemetry-exporter-otlp
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0.30'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0.30'
26
60
  - !ruby/object:Gem::Dependency
27
61
  name: rake
28
62
  requirement: !ruby/object:Gem::Requirement
@@ -122,11 +156,14 @@ files:
122
156
  - lib/bitfab/db_snapshot.rb
123
157
  - lib/bitfab/http_client.rb
124
158
  - lib/bitfab/mock_override.rb
159
+ - lib/bitfab/otel.rb
160
+ - lib/bitfab/otel_collector.rb
125
161
  - lib/bitfab/replay.rb
126
162
  - lib/bitfab/replay_branch.rb
127
163
  - lib/bitfab/serialize.rb
128
164
  - lib/bitfab/span_context.rb
129
165
  - lib/bitfab/traceable.rb
166
+ - lib/bitfab/transport.rb
130
167
  - lib/bitfab/version.rb
131
168
  - lib/bitfab/warn_once.rb
132
169
  homepage: https://bitfab.ai