bitfab 0.36.4 → 0.36.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5578e8fdcdd81610b4e65fa94a5e9be88339e816742f6ba7fea81b53867eedf5
4
- data.tar.gz: 308d35969ac643d1ddcfb76c169a6108906c958275b392f7d159ea647b0bf63c
3
+ metadata.gz: dd73e07889e4f74cd94c6ca534e423a7aa95e3ce6f1e1ba306e9fe33df8de8df
4
+ data.tar.gz: fb2eb3cd99066d8338f6ad03d18bc3a8de9f3955659aa66835c83066a95d3595
5
5
  SHA512:
6
- metadata.gz: 2c0511909223595a7792e780d0c77b8c0f5ef2d239e4b2a5dda7946a63fbe4801893ae02ba3392b74520ad9d82844e519d3319a900f22c30bef4857e616c0420
7
- data.tar.gz: 0ae83e2f731bc2f6fa9f564b6fe8754b7fff82c5ae03a914067cca6fb75f4ddb10e935bba8976de6f0dabe7025812e49d9392e2ace61b5202c02d80b21fe00aa
6
+ metadata.gz: b7155a3d0426c00f57668975485f2ba722bd3f0555dd897024f586290b36265f5851d49cc88242eb552421758b9d624b31e21edfb8290e68f693110e8b93cd32
7
+ data.tar.gz: 87ae26a27f8864294f431b80d87f0a7f768fdb2a2f09786929dad90d61316ce7c1106e75fe9271c63260cebe3545a2e73676e94295aa281f4d24c0d101184b0f
@@ -12,18 +12,29 @@ module Bitfab
12
12
  # batches) ride uncompressed.
13
13
  MIN_COMPRESSED_BYTES = 8_192
14
14
 
15
+ PreparedRequest = Struct.new(:body, :content_encoding, :raw_bytes, :wire_bytes)
16
+
15
17
  module_function
16
18
 
17
19
  # Returns the body to send and the Content-Encoding it carries, or nil when
18
20
  # the body is sent as-is. Compression is best-effort: any failure sends the
19
21
  # original body rather than dropping the span.
20
22
  def encode_request_body(body)
21
- return [body, nil] if ENV[DISABLE_COMPRESSION_ENV]
22
- return [body, nil] if body.bytesize < MIN_COMPRESSED_BYTES
23
+ prepared = prepare_request_body(body)
24
+ [prepared.body, prepared.content_encoding]
25
+ end
26
+
27
+ def prepare_request_body(body)
28
+ size = body.bytesize
29
+ return PreparedRequest.new(body, nil, size, size) if ENV[DISABLE_COMPRESSION_ENV]
30
+ return PreparedRequest.new(body, nil, size, size) if size < MIN_COMPRESSED_BYTES
31
+
32
+ compressed = Zlib.gzip(body)
33
+ return PreparedRequest.new(body, nil, size, size) if compressed.bytesize >= size
23
34
 
24
- [Zlib.gzip(body), "gzip"]
35
+ PreparedRequest.new(compressed, "gzip", size, compressed.bytesize)
25
36
  rescue
26
- [body, nil]
37
+ PreparedRequest.new(body, nil, body.bytesize, body.bytesize)
27
38
  end
28
39
  end
29
40
  end
@@ -12,7 +12,15 @@ require_relative "version"
12
12
  require_relative "warn_once"
13
13
 
14
14
  module Bitfab
15
+ # What a caller learns about one tracked trace once it takes it back.
16
+ DeliveryReport = Struct.new(:span_count, :closed, :delivered, keyword_init: true)
17
+
18
+ TraceDelivery = Struct.new(:submitted_span_ids, :acked_span_ids, :closed, :closing_acked,
19
+ keyword_init: true)
20
+
15
21
  class HttpClient
22
+ OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces"
23
+
16
24
  REPLAY_DB_BRANCH_REQUEST_TIMEOUT_SECONDS = 300
17
25
  private_constant :REPLAY_DB_BRANCH_REQUEST_TIMEOUT_SECONDS
18
26
 
@@ -23,6 +31,13 @@ module Bitfab
23
31
  @service_url = (service_url || DEFAULT_SERVICE_URL).chomp("/")
24
32
  @timeout = timeout
25
33
  @transport_mutex = Mutex.new
34
+ # Eager for the same reason the transport's mutex is: `||=` racing between
35
+ # the export threads recording acks would hand each a different mutex.
36
+ @delivery_mutex = Mutex.new
37
+ @trace_deliveries = {}
38
+ @delivery_pid = Process.pid
39
+ @carrier_seq_mutex = Mutex.new
40
+ @carrier_seq = 0
26
41
  @transport = nil
27
42
  @closed = false
28
43
  end
@@ -58,6 +73,16 @@ module Bitfab
58
73
  # POST an already-encoded body. The span transport encodes its own batches,
59
74
  # so routing them back through #request would encode the same data twice.
60
75
  def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1)
76
+ send_prepared(
77
+ endpoint,
78
+ Compress.prepare_request_body(body),
79
+ timeout:,
80
+ max_retries:,
81
+ retry_delay:
82
+ )
83
+ end
84
+
85
+ def send_prepared(endpoint, prepared, timeout: nil, max_retries: 1, retry_delay: 0.1)
61
86
  uri = URI("#{@service_url}#{endpoint}")
62
87
  request_timeout = timeout || @timeout
63
88
 
@@ -70,9 +95,8 @@ module Bitfab
70
95
  http.read_timeout = request_timeout
71
96
 
72
97
  req = Net::HTTP::Post.new(uri.path, headers)
73
- encoded_body, content_encoding = Compress.encode_request_body(body)
74
- req["Content-Encoding"] = content_encoding if content_encoding
75
- req.body = encoded_body
98
+ req["Content-Encoding"] = prepared.content_encoding if prepared.content_encoding
99
+ req.body = prepared.body
76
100
 
77
101
  response = http.request(req)
78
102
 
@@ -99,7 +123,11 @@ module Bitfab
99
123
 
100
124
  # Queue an external span on this client's trace transport (fire-and-forget).
101
125
  def send_external_span(payload)
102
- trace_transport&.submit("external_span", payload.merge("sdkVersion" => VERSION))
126
+ trace_transport&.submit(
127
+ "external_span",
128
+ payload.merge("sdkVersion" => VERSION),
129
+ recorded_meta("external_span", payload, carrier_ref(payload))
130
+ )
103
131
  end
104
132
 
105
133
  # Make a GET request to the Bitfab API.
@@ -245,7 +273,15 @@ module Bitfab
245
273
 
246
274
  # Queue an external trace on this client's trace transport (fire-and-forget).
247
275
  def send_external_trace(payload)
248
- trace_transport&.submit("external_trace", payload.merge("sdkVersion" => VERSION))
276
+ trace_transport&.submit(
277
+ "external_trace",
278
+ payload.merge("sdkVersion" => VERSION),
279
+ recorded_meta(
280
+ "external_trace",
281
+ payload,
282
+ (payload["completed"] == true) ? carrier_ref(payload) : nil
283
+ )
284
+ )
249
285
  end
250
286
 
251
287
  # Read the replay traces the server has fully persisted so far.
@@ -259,6 +295,75 @@ module Bitfab
259
295
 
260
296
  private
261
297
 
298
+ # The delivery identity of a carrier, read from the payload here because
299
+ # this is where the payload shape is owned. The transport is handed the
300
+ # result and never looks inside a payload itself.
301
+ # Everything the transport needs to know about a carrier, derived here
302
+ # because this is where the payload shape is owned. The transport applies
303
+ # these and never looks inside a payload itself.
304
+ def carrier_meta(operation, payload, ref)
305
+ Otel::CarrierMeta.new(
306
+ ref:,
307
+ name: carrier_name(operation, payload),
308
+ started_at: payload_timestamp(payload, "started_at"),
309
+ ended_at: payload_timestamp(payload, "ended_at"),
310
+ errored: payload_error?(payload)
311
+ )
312
+ end
313
+
314
+ def carrier_name(operation, payload)
315
+ raw_span = payload["rawSpan"]
316
+ if operation == "external_span" && raw_span.is_a?(Hash)
317
+ name = raw_span.dig("span_data", "name")
318
+ return name if name.is_a?(String)
319
+ end
320
+ return payload["traceFunctionKey"] if payload["traceFunctionKey"].is_a?(String)
321
+
322
+ "bitfab.#{operation}"
323
+ end
324
+
325
+ def payload_timestamp(payload, field)
326
+ raw = payload.dig("rawSpan", field) if payload["rawSpan"].is_a?(Hash)
327
+ if raw.nil?
328
+ raw_trace = payload["externalTrace"] || payload["rawTrace"]
329
+ raw = raw_trace[field] if raw_trace.is_a?(Hash)
330
+ end
331
+ return nil unless raw.is_a?(String)
332
+
333
+ begin
334
+ Time.iso8601(raw)
335
+ rescue ArgumentError
336
+ nil
337
+ end
338
+ end
339
+
340
+ def payload_error?(payload)
341
+ raw_span = payload["rawSpan"]
342
+ return true if raw_span.is_a?(Hash) && !raw_span.dig("span_data", "error").nil?
343
+
344
+ errors = payload["errors"]
345
+ errors.is_a?(Array) && !errors.empty?
346
+ end
347
+
348
+ def carrier_ref(payload)
349
+ trace_id = payload["sourceTraceId"]
350
+ unless trace_id.is_a?(String)
351
+ raw_trace = payload["externalTrace"] || payload["rawTrace"]
352
+ trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
353
+ end
354
+ return nil unless trace_id.is_a?(String)
355
+
356
+ raw_span = payload["rawSpan"]
357
+ return Otel::CarrierRef.new(trace_id:) if raw_span.nil?
358
+
359
+ span_id = raw_span["id"] if raw_span.is_a?(Hash)
360
+ # A counter, not a clock: CLOCK_MONOTONIC repeats across calls close
361
+ # together, and two carriers sharing an id collapse into one ledger entry,
362
+ # so a single ack would mark both delivered.
363
+ span_id = next_submission_id unless span_id.is_a?(String)
364
+ Otel::CarrierRef.new(trace_id:, span_id:)
365
+ end
366
+
262
367
  # Returns nil once the client is closed. A stray traced call after close
263
368
  # must not crash the host app, and raising here would be swallowed by the
264
369
  # span finalize path, dropping the span with no explanation.
@@ -274,13 +379,184 @@ module Bitfab
274
379
  end
275
380
 
276
381
  @transport ||= Transport.create_trace_transport(
277
- direct_sender: method(:send_transport_request)
382
+ direct_sender: method(:deliver_carriers),
383
+ on_delivered: method(:record_delivered_carriers)
384
+ )
385
+ end
386
+ end
387
+
388
+ # Post one encoded batch and decide what the server's answer means, so the
389
+ # transport never reads a response. Rejections and permanent statuses come
390
+ # back as a non-retryable DeliveryError; anything the server might still
391
+ # accept on a second try comes back retryable.
392
+ def deliver_carriers(request, timeout)
393
+ response = begin
394
+ # send_prepared, not send_encoded: the exporter already encoded this
395
+ # batch to size the request, and re-encoding would do it twice.
396
+ send_prepared(OTLP_TRACES_ENDPOINT, request, timeout:, max_retries: 1)
397
+ rescue => e
398
+ status = http_status(e)
399
+ raise Otel::DeliveryError.new(
400
+ "OTLP ingestion failed: #{e.message}",
401
+ # OTLP's retryable set, plus 500. Every other 4xx is the server's
402
+ # verdict on the payload and will be the same next time.
403
+ #
404
+ # 500 is a deliberate deviation: OTLP treats it as the app being
405
+ # broken, which assumes a collector that fails deterministically.
406
+ # Bitfab ingestion answers every unhandled error with 500, so a
407
+ # connection blip or a cold start arrives here indistinguishable from
408
+ # a real fault, and giving up on the first one drops spans a second
409
+ # attempt would deliver.
410
+ retryable: status.nil? || [429, 500, 502, 503, 504].include?(status),
411
+ oversized: status == 413,
412
+ retry_after_ms: retry_after_ms(e)
278
413
  )
279
414
  end
415
+
416
+ partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
417
+ return unless partial_success.is_a?(Hash)
418
+
419
+ rejected = partial_success["rejectedSpans"]
420
+ return if rejected.nil? || rejected.to_s == "0"
421
+
422
+ # The server's verdict on the payload, not a transient fault.
423
+ raise Otel::DeliveryError.new(
424
+ "OTLP ingestion rejected #{rejected} span(s): " \
425
+ "#{partial_success["errorMessage"] || "no reason provided"}"
426
+ )
427
+ end
428
+
429
+ # Retry-After as milliseconds. The header is either a delay in seconds or an
430
+ # HTTP date; both forms appear in the wild, so both are read. Anything else,
431
+ # or a date already past, yields nil so the caller backs off on its own.
432
+ def retry_after_ms(error)
433
+ return nil unless error.respond_to?(:response)
434
+
435
+ response = error.response
436
+ header = response.respond_to?(:[]) ? response["Retry-After"] : nil
437
+ return nil if header.nil? || header.to_s.empty?
438
+
439
+ seconds = Float(header, exception: false)
440
+ return (seconds >= 0) ? seconds * 1000 : nil if seconds
441
+
442
+ begin
443
+ [0, (Time.httpdate(header.to_s) - Time.now) * 1000].max
444
+ rescue ArgumentError
445
+ nil
446
+ end
447
+ end
448
+
449
+ def http_status(error)
450
+ return nil unless error.respond_to?(:response)
451
+
452
+ response = error.response
453
+ response.respond_to?(:code) ? response.code.to_i : nil
454
+ end
455
+
456
+ public
457
+
458
+ # Start tracking delivery for trace_ids. Nothing is recorded for a trace
459
+ # that was never tracked, so ordinary tracing costs no bookkeeping at all.
460
+ def track_trace_deliveries(trace_ids)
461
+ @delivery_mutex.synchronize do
462
+ trace_ids.each do |trace_id|
463
+ trace_deliveries[trace_id] ||= TraceDelivery.new(
464
+ submitted_span_ids: Set.new, acked_span_ids: Set.new,
465
+ closed: false, closing_acked: false
466
+ )
467
+ end
468
+ end
469
+ end
470
+
471
+ # Whether any tracked trace has had its closing carrier submitted.
472
+ def closed_deliveries?(trace_ids)
473
+ @delivery_mutex.synchronize do
474
+ trace_ids.any? { |trace_id| trace_deliveries[trace_id]&.closed }
475
+ end
476
+ end
477
+
478
+ # Report what each tracked trace submitted and whether the server confirmed
479
+ # it, and stop tracking them. Every id passed is freed, so a caller cannot
480
+ # leak a record for a trace that never closed.
481
+ #
482
+ # delivered is only meaningful once a flush has settled: acks land before an
483
+ # export returns, so a flush that reported success has already collected
484
+ # every ack it is going to collect.
485
+ def take_trace_deliveries(trace_ids)
486
+ @delivery_mutex.synchronize do
487
+ trace_ids.each_with_object({}) do |trace_id, acc|
488
+ delivery = trace_deliveries.delete(trace_id)
489
+ next if delivery.nil?
490
+
491
+ acc[trace_id] = DeliveryReport.new(
492
+ span_count: delivery.submitted_span_ids.size,
493
+ closed: delivery.closed,
494
+ delivered: delivery.closing_acked &&
495
+ delivery.submitted_span_ids.subset?(delivery.acked_span_ids)
496
+ )
497
+ end
498
+ end
499
+ end
500
+
501
+ private
502
+
503
+ def record_submitted_carrier(ref)
504
+ return if ref.nil?
505
+
506
+ @delivery_mutex.synchronize do
507
+ delivery = trace_deliveries[ref.trace_id]
508
+ next if delivery.nil?
509
+
510
+ if ref.span_id.nil?
511
+ delivery.closed = true
512
+ else
513
+ delivery.submitted_span_ids << ref.span_id
514
+ end
515
+ end
280
516
  end
281
517
 
282
- def send_transport_request(endpoint, body, timeout)
283
- send_encoded(endpoint, body, timeout:, max_retries: 1)
518
+ # Ingestion commits every carrier in a request before it answers, so a
519
+ # delivered ref is proof its row exists: the same fact the replay status
520
+ # endpoint would report, already in hand.
521
+ #
522
+ # Called on exporter threads, hence the mutex.
523
+ def record_delivered_carriers(refs)
524
+ @delivery_mutex.synchronize do
525
+ refs.each do |ref|
526
+ delivery = trace_deliveries[ref.trace_id]
527
+ next if delivery.nil?
528
+
529
+ if ref.span_id.nil?
530
+ delivery.closing_acked = true
531
+ else
532
+ delivery.acked_span_ids << ref.span_id
533
+ end
534
+ end
535
+ end
536
+ end
537
+
538
+ # Build a carrier's meta and record what it adds to its expected set.
539
+ def recorded_meta(operation, payload, ref)
540
+ record_submitted_carrier(ref)
541
+ carrier_meta(operation, payload, ref)
542
+ end
543
+
544
+ # The ledger records what the PARENT submitted. The child's transport is
545
+ # rebuilt empty and its carrier refs are dropped, so nothing inherited here
546
+ # will ever be exported or acked: a barrier reading it would wait on acks
547
+ # that cannot arrive, or count the parent's deliveries as its own and finish
548
+ # having sent nothing. Ruby has no after-fork hook, so this checks the pid
549
+ # the same way Otel.ensure_process_state does.
550
+ def next_submission_id
551
+ @carrier_seq_mutex.synchronize { "submission-#{@carrier_seq += 1}" }
552
+ end
553
+
554
+ def trace_deliveries
555
+ if @delivery_pid != Process.pid
556
+ @delivery_pid = Process.pid
557
+ @trace_deliveries = {}
558
+ end
559
+ @trace_deliveries
284
560
  end
285
561
 
286
562
  # Normalize each entry to a hash with stable string keys, accepting either
data/lib/bitfab/otel.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "time"
6
6
  require "opentelemetry/sdk"
7
7
 
8
+ require_relative "compress"
8
9
  require_relative "serialize"
9
10
  require_relative "version"
10
11
  require_relative "warn_once"
@@ -18,10 +19,10 @@ module Bitfab
18
19
  module Otel
19
20
  OPERATION_ATTRIBUTE = "bitfab.operation"
20
21
  PAYLOAD_ATTRIBUTE = "bitfab.payload"
21
- OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces"
22
22
  MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
23
23
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
24
24
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
25
+ MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
25
26
  MAX_QUEUE_SIZE = 8_192
26
27
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
27
28
  DIRECT_MAX_REQUEST_BATCH_SIZE = 8
@@ -30,7 +31,12 @@ module Bitfab
30
31
  SCHEDULE_DELAY_MILLIS = 5_000
31
32
  EXPORT_TIMEOUT_MILLIS = 30_000
32
33
  EXPORT_RETRIES = 3
33
- RETRY_DELAY_SECONDS = 0.1
34
+ RETRY_BASE_DELAY_MILLIS = 100
35
+ # Ceiling on the exponential growth of our OWN backoff. It does not bound a
36
+ # wait the server asked for: OTLP says to honor Retry-After, and calls data
37
+ # dropped while throttled the outcome to avoid. What bounds an honored wait
38
+ # is the export budget, since the processor kills a longer export.
39
+ RETRY_BACKOFF_CEILING_MILLIS = 5_000
34
40
 
35
41
  # The comma that joins adjacent spans in the request's span list.
36
42
  SPAN_SEPARATOR_BYTES = 1
@@ -42,7 +48,22 @@ module Bitfab
42
48
  # Encoding once and remembering the size is what keeps request packing
43
49
  # linear: sizing a candidate batch by re-encoding the whole request
44
50
  # re-escapes every carrier's bitfab.payload string on every span considered.
45
- EncodedSpan = Struct.new(:encoded, :size)
51
+ EncodedSpan = Struct.new(:encoded, :size, :ref)
52
+
53
+ # Which carrier a payload is, for delivery accounting only. Supplied by the
54
+ # caller that built the payload: the transport never reads inside one.
55
+ #
56
+ # span_id is nil for the carrier that closes a trace, which is what tells
57
+ # the transport the trace's expected set has stopped growing.
58
+ CarrierRef = Struct.new(:trace_id, :span_id, keyword_init: true)
59
+
60
+ # Everything the transport needs to know ABOUT a payload without reading
61
+ # one. Supplied by the caller that built it; each field falls back to
62
+ # something the transport can decide without looking inside.
63
+ #
64
+ # ref is nil for carriers nobody accounts for, name defaults to
65
+ # "bitfab.<operation>", nil timestamps let OTel stamp the carrier itself.
66
+ CarrierMeta = Struct.new(:ref, :name, :started_at, :ended_at, :errored, keyword_init: true)
46
67
 
47
68
  # The invariant head and tail of an OTLP request for one export window. Key
48
69
  # order matches what JSON.generate emits for the equivalent Hash, so a body
@@ -61,8 +82,21 @@ module Bitfab
61
82
 
62
83
  INVALID_SPAN_ID = ("\0" * 8).b
63
84
 
64
- PayloadTooLargeError = Class.new(StandardError)
65
- PartialSuccessError = Class.new(StandardError)
85
+ # Why a delivery failed, in the only two terms the transport acts on. A
86
+ # sender classifies everything it can see, including a network fault
87
+ # carrying no verdict; anything else reaching the transport is a fault in
88
+ # the sender and is not retried.
89
+ class DeliveryError < StandardError
90
+ attr_reader :retryable, :oversized, :retry_after_ms
91
+
92
+ def initialize(message, retryable: false, oversized: false, retry_after_ms: nil)
93
+ super(message)
94
+ @retryable = retryable
95
+ @oversized = oversized
96
+ # How long the server asked us to wait, when it said so.
97
+ @retry_after_ms = retry_after_ms
98
+ end
99
+ end
66
100
 
67
101
  class << self
68
102
  def max_request_bytes_from_env
@@ -95,9 +129,10 @@ module Bitfab
95
129
  DEFAULT_EXPORT_CONCURRENCY
96
130
  end
97
131
 
98
- def create_transport(direct_sender:)
132
+ def create_transport(direct_sender:, on_delivered: nil)
99
133
  BatchTransport.new(
100
134
  direct_sender:,
135
+ on_delivered:,
101
136
  export_concurrency: export_concurrency_from_env,
102
137
  max_request_bytes: max_request_bytes_from_env
103
138
  )
@@ -129,38 +164,22 @@ module Bitfab
129
164
  end
130
165
  end
131
166
 
132
- # Count the spans submitted for each replay trace and forget them, so the
133
- # replay barrier knows how many spans the server must have persisted
134
- # before the run can be finalized.
135
- def take_replay_span_counts(trace_ids)
167
+ # Bounded, because the only thing that removes an entry is the encode
168
+ # that consumes it: a span the processor drops under backpressure never
169
+ # reaches encode, and its ref would otherwise sit here for the life of the
170
+ # process. The queue is the ceiling on how many spans can be waiting at
171
+ # once, so anything older than that is already unreachable. Evicting a
172
+ # live one costs an ack, and the barrier falls back to asking the server.
173
+ def record_carrier_ref(span_id, ref)
136
174
  ensure_process_state
137
175
  submission_mutex.synchronize do
138
- counts = trace_ids.each_with_object({}) do |trace_id, acc|
139
- next unless replay_trace_submissions.include?(trace_id)
140
-
141
- acc[trace_id] = (trace_submission_span_ids.delete(trace_id) || Set.new).size
142
- end
143
- replay_trace_submissions.subtract(trace_ids)
144
- counts
176
+ carrier_refs[span_id] = ref
177
+ carrier_refs.shift while carrier_refs.size > MAX_QUEUE_SIZE
145
178
  end
146
179
  end
147
180
 
148
- def record_submission(operation, payload)
149
- ensure_process_state
150
- source_trace_id = payload["sourceTraceId"]
151
- unless source_trace_id.is_a?(String)
152
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
153
- source_trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
154
- end
155
- return unless source_trace_id.is_a?(String)
156
-
157
- submission_mutex.synchronize do
158
- if operation == "external_span"
159
- record_span_submission(source_trace_id, payload)
160
- elsif payload["completed"] == true
161
- record_trace_completion(source_trace_id, payload)
162
- end
163
- end
181
+ def take_carrier_ref(span_id)
182
+ submission_mutex.synchronize { carrier_refs.delete(span_id) }
164
183
  end
165
184
 
166
185
  def monotonic_now
@@ -179,31 +198,14 @@ module Bitfab
179
198
  reset_state_after_fork
180
199
  end
181
200
 
182
- # Replay submission counts belong to the parent's run, never the child's.
201
+ # Carrier refs belong to the parent's run, never the child's.
183
202
  def reset_state_after_fork
184
203
  @state_pid = Process.pid
185
- @trace_submission_span_ids = {}
186
- @replay_trace_submissions = Set.new
204
+ @carrier_refs = {}
187
205
  end
188
206
 
189
207
  private
190
208
 
191
- def record_span_submission(source_trace_id, payload)
192
- raw_span = payload["rawSpan"]
193
- span_id = raw_span["id"] if raw_span.is_a?(Hash)
194
- span_id = "submission-#{monotonic_now}" unless span_id.is_a?(String)
195
- (trace_submission_span_ids[source_trace_id] ||= Set.new) << span_id
196
- end
197
-
198
- def record_trace_completion(source_trace_id, payload)
199
- if payload["testRunId"].is_a?(String)
200
- replay_trace_submissions << source_trace_id
201
- trace_submission_span_ids[source_trace_id] ||= Set.new
202
- else
203
- trace_submission_span_ids.delete(source_trace_id)
204
- end
205
- end
206
-
207
209
  def current_process_transports
208
210
  live_transports_mutex.synchronize { live_transports.select { |t| t.owner_pid == Process.pid } }
209
211
  end
@@ -220,22 +222,24 @@ module Bitfab
220
222
  @submission_mutex ||= Mutex.new
221
223
  end
222
224
 
223
- def trace_submission_span_ids
224
- @trace_submission_span_ids ||= {}
225
- end
226
-
227
- def replay_trace_submissions
228
- @replay_trace_submissions ||= Set.new
225
+ def carrier_refs
226
+ @carrier_refs ||= {}
229
227
  end
230
228
  end
231
229
 
232
230
  # Encodes carrier spans as OTLP/JSON and posts them straight to Bitfab.
233
231
  class DirectExporter
234
- def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:)
232
+ def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:, on_delivered: nil)
235
233
  @direct_sender = direct_sender
234
+ @on_delivered = on_delivered
236
235
  @max_request_bytes = max_request_bytes
237
236
  @max_request_batch_size = max_request_batch_size
238
237
  @export_concurrency = export_concurrency
238
+ # Eager, unlike the module-level mutexes: this one is first touched by
239
+ # the export threads fanning out a batch, and `||=` racing between them
240
+ # would hand each a different mutex and no mutual exclusion at all.
241
+ @throttle_mutex = Mutex.new
242
+ @throttled_until = 0.0
239
243
  end
240
244
 
241
245
  def export(spans, timeout: nil)
@@ -314,79 +318,165 @@ module Bitfab
314
318
  end
315
319
 
316
320
  def send_batch(envelope, batch)
317
- if batch.size > @max_request_bytes
321
+ request_spans = batch.spans
322
+ request_raw_bytes = batch.size
323
+ already_trimmed = false
324
+ loop do
325
+ if request_raw_bytes <= MAX_DECOMPRESSED_REQUEST_BYTES
326
+ prepared = Compress.prepare_request_body(Encoder.encode_request(envelope, request_spans))
327
+ if prepared.wire_bytes <= @max_request_bytes
328
+ send_with_retries(prepared)
329
+ # Refs come from the original spans: trimming rebuilds a span
330
+ # without one, and a trimmed carrier still reached the server
331
+ # under its own identity.
332
+ report_delivered(batch.spans)
333
+ return true
334
+ end
335
+ end
336
+
337
+ if batch.spans.size != 1
338
+ warn_oversized(batch.spans.size)
339
+ return false
340
+ end
341
+ if already_trimmed
342
+ warn_oversized(1)
343
+ return false
344
+ end
345
+ trimmed = Encoder.trim_span(batch.spans.first)
346
+ if trimmed.nil?
347
+ warn_oversized(1)
348
+ return false
349
+ end
350
+ request_spans = [trimmed]
351
+ request_raw_bytes = envelope.size + trimmed.size
352
+ already_trimmed = true
353
+ end
354
+ rescue DeliveryError => e
355
+ # Returned, never re-raised: a raise here escapes the export worker and
356
+ # aborts the whole batch fan-out at join, where TypeScript and Python
357
+ # record a failed export and let sibling batches finish.
358
+ if e.oversized
318
359
  warn_oversized(batch.spans.size)
319
- return false
360
+ else
361
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
320
362
  end
321
-
322
- send_with_retries(Encoder.encode_request(envelope, batch.spans))
323
- true
324
- rescue PayloadTooLargeError
325
- warn_oversized(batch.spans.size)
326
- false
327
- rescue PartialSuccessError
328
363
  false
329
364
  rescue => e
330
- Bitfab.warn_once(
331
- "otel-export-failed",
332
- "failed to export an OpenTelemetry span batch (further occurrences " \
333
- "suppressed): #{e.message}"
334
- )
365
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
335
366
  false
336
367
  end
337
368
 
338
369
  def warn_oversized(span_count)
339
370
  subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
340
- Bitfab.warn_once(
341
- "otel-payload-too-large",
342
- "#{subject} exceeded the ingestion request limit and could not be exported"
343
- )
371
+ Bitfab.warn_always("#{subject} exceeded the ingestion request limit and could not be exported")
344
372
  end
345
373
 
346
- def send_with_retries(body)
374
+ def send_with_retries(request)
347
375
  attempt = 0
376
+ # One budget for the whole exchange, waits included: the processor kills
377
+ # the export at this deadline, so a wait past it cannot be served.
378
+ deadline = Otel.monotonic_now + EXPORT_TIMEOUT_MILLIS / 1000.0
348
379
  begin
349
380
  attempt += 1
350
- response = @direct_sender.call(OTLP_TRACES_ENDPOINT, body, EXPORT_TIMEOUT_MILLIS / 1000.0)
351
- check_partial_success(response)
352
- rescue PartialSuccessError
353
- raise
381
+ await_throttle(deadline)
382
+ @direct_sender.call(request, [0, deadline - Otel.monotonic_now].max)
354
383
  rescue => e
355
- raise PayloadTooLargeError if response_status(e) == 413
384
+ record_throttle(e)
385
+ raise if oversized?(e)
356
386
  raise if attempt >= EXPORT_RETRIES || !retryable?(e)
357
387
 
358
- sleep(RETRY_DELAY_SECONDS)
388
+ wait = retry_wait_seconds(e, attempt - 1, deadline - Otel.monotonic_now)
389
+ raise if wait.nil?
390
+
391
+ sleep(wait)
359
392
  retry
360
393
  end
361
394
  end
362
395
 
363
- def check_partial_success(response)
364
- partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
365
- return unless partial_success.is_a?(Hash)
396
+ # Announce the carriers a request delivered. Wrapped because a listener
397
+ # that raises must never turn a delivered batch into a failed export.
398
+ def report_delivered(spans)
399
+ return if @on_delivered.nil?
366
400
 
367
- rejected = partial_success["rejectedSpans"]
368
- return if rejected.nil? || rejected.to_s == "0"
401
+ refs = spans.filter_map(&:ref)
402
+ return if refs.empty?
369
403
 
370
- Bitfab.warn_once(
371
- "otel-partial-success",
372
- "OTLP ingestion rejected #{rejected} span(s): " \
373
- "#{partial_success["errorMessage"] || "no reason provided"}"
374
- )
375
- raise PartialSuccessError
404
+ begin
405
+ @on_delivered.call(refs)
406
+ rescue => e
407
+ Bitfab.warn_once("otel-delivery-listener-failed", "a delivery listener raised: #{e.message}")
408
+ end
376
409
  end
377
410
 
378
- def response_status(error)
379
- return nil unless error.respond_to?(:response)
411
+ # Only what the sender classified. Anything else reaching here is a fault
412
+ # in the sender itself, and retrying a deterministic bug just delays it.
413
+ def retryable?(error)
414
+ error.is_a?(DeliveryError) && error.retryable
415
+ end
416
+
417
+ def oversized?(error)
418
+ error.is_a?(DeliveryError) && error.oversized
419
+ end
420
+
421
+ # How long to wait before the next send attempt, or nil to stop trying.
422
+ #
423
+ # A server that sent Retry-After has told us when it wants us back, so
424
+ # that wait is honored exactly. Clamping it would return early, which is
425
+ # the single thing the server asked us not to do; when the wait is longer
426
+ # than we are willing to hold a batch, the honest answer is to give up
427
+ # rather than come back sooner and add load to something already
428
+ # struggling.
429
+ #
430
+ # Absent an instruction, back off exponentially so a struggling server is
431
+ # not hit on a fixed cadence, and jitter it so every client in a fleet
432
+ # does not return in lockstep.
433
+ # Half the budget, not all of it: a wait is only worth taking if what is
434
+ # left afterwards can still carry the request. Spending the whole budget
435
+ # waiting means being killed mid-wait, losing the batch anyway.
436
+ def retry_wait_seconds(error, attempt, remaining_seconds)
437
+ affordable = remaining_seconds / 2.0
438
+ requested = error.is_a?(DeliveryError) ? error.retry_after_ms : nil
439
+ if requested
440
+ return nil unless requested / 1000.0 < affordable
441
+
442
+ return requested / 1000.0
443
+ end
380
444
 
381
- response = error.response
382
- response.respond_to?(:code) ? response.code.to_i : nil
445
+ backoff = [RETRY_BASE_DELAY_MILLIS * (2**attempt), RETRY_BACKOFF_CEILING_MILLIS].min
446
+ jittered = (backoff / 2.0 + rand * (backoff / 2.0)) / 1000.0
447
+ (jittered < affordable) ? jittered : nil
383
448
  end
384
449
 
385
- def retryable?(error)
386
- status = response_status(error)
387
- return true if status.nil?
450
+ # Remember a throttle the server asked for, so the requests fanned out
451
+ # alongside this one respect it too. Delaying only the request that was
452
+ # refused leaves the others in the window hitting a server that just asked
453
+ # for room.
454
+ def record_throttle(error)
455
+ requested = error.is_a?(DeliveryError) ? error.retry_after_ms : nil
456
+ return unless requested
457
+
458
+ @throttle_mutex.synchronize do
459
+ @throttled_until = [@throttled_until.to_f, Otel.monotonic_now + requested / 1000.0].max
460
+ end
461
+ end
462
+
463
+ # Waits out an active throttle, or reports the batch undeliverable when
464
+ # the throttle outlasts what we are willing to hold it for. Either way
465
+ # nothing is sent while the server has asked us to stay away.
466
+ def await_throttle(deadline)
467
+ remaining = @throttle_mutex.synchronize { @throttled_until.to_f - Otel.monotonic_now }
468
+ return if remaining <= 0
469
+
470
+ # Waited out, not refused: OTLP asks the client to hold off until the
471
+ # window passes. Only a throttle outliving what the budget can serve is
472
+ # refused, because the processor would kill the wait before it sent.
473
+ if remaining >= (deadline - Otel.monotonic_now) / 2.0
474
+ raise DeliveryError.new(
475
+ "OTLP ingestion is throttled for another #{remaining.round(1)}s, longer than the export budget"
476
+ )
477
+ end
388
478
 
389
- status >= 500 || [408, 425, 429].include?(status)
479
+ sleep(remaining)
390
480
  end
391
481
  end
392
482
 
@@ -434,10 +524,11 @@ module Bitfab
434
524
 
435
525
  def initialize(direct_sender:, max_export_batch_size: nil,
436
526
  max_request_batch_size: DIRECT_MAX_REQUEST_BATCH_SIZE, max_queue_size: MAX_QUEUE_SIZE,
437
- export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil)
527
+ export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil, on_delivered: nil)
438
528
  raise ArgumentError, "max_request_batch_size must be a positive integer" unless max_request_batch_size.positive?
439
529
 
440
530
  @direct_sender = direct_sender
531
+ @on_delivered = on_delivered
441
532
  @max_export_batch_size = max_export_batch_size || DIRECT_MAX_EXPORT_BATCH_SIZE
442
533
  @max_request_batch_size = max_request_batch_size
443
534
  @max_queue_size = max_queue_size
@@ -451,15 +542,18 @@ module Bitfab
451
542
  Otel.register_transport(self)
452
543
  end
453
544
 
454
- def submit(operation, payload)
455
- Otel.record_submission(operation, payload)
545
+ def submit(operation, payload, meta = nil)
546
+ meta ||= CarrierMeta.new
456
547
  # Encoding is the expensive part of a submit and needs no mutual
457
548
  # exclusion, so it stays outside the lock.
458
- name = Encoder.span_name(operation, payload)
459
- encoded_payload = Serialize.safe_generate(payload)
460
- started_at = Encoder.timestamp(payload, "started_at")
461
- ended_at = Encoder.timestamp(payload, "ended_at")
462
- errored = Encoder.error?(payload)
549
+ name = meta.name || "bitfab.#{operation}"
550
+ encoded_payload = Serialize.safe_generate(
551
+ payload,
552
+ max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
553
+ )
554
+ started_at = meta.started_at || Time.now
555
+ ended_at = meta.ended_at || Time.now
556
+ errored = meta.errored
463
557
 
464
558
  # Pipeline construction and the closed check are serialized: concurrent
465
559
  # first submits in a forked child would otherwise each build a pipeline,
@@ -479,6 +573,7 @@ module Bitfab
479
573
  },
480
574
  start_timestamp: started_at
481
575
  )
576
+ Otel.record_carrier_ref(span.context.hex_span_id, meta.ref) if meta.ref
482
577
  span.status = OpenTelemetry::Trace::Status.error if errored
483
578
  span.finish(end_timestamp: ended_at)
484
579
  end
@@ -603,7 +698,8 @@ module Bitfab
603
698
  direct_sender: @direct_sender,
604
699
  max_request_bytes: @max_request_bytes,
605
700
  max_request_batch_size: @max_request_batch_size,
606
- export_concurrency: @export_concurrency
701
+ export_concurrency: @export_concurrency,
702
+ on_delivered: @on_delivered
607
703
  )
608
704
  end
609
705
  end
@@ -613,7 +709,21 @@ module Bitfab
613
709
  class << self
614
710
  def encode_span(span)
615
711
  encoded = JSON.generate(span_to_otlp(span))
712
+ EncodedSpan.new(encoded, encoded.bytesize, Otel.take_carrier_ref(span.hex_span_id))
713
+ end
714
+
715
+ def trim_span(span)
716
+ carrier = JSON.parse(span.encoded)
717
+ attribute = carrier.fetch("attributes").find { |entry| entry["key"] == PAYLOAD_ATTRIBUTE }
718
+ return nil if attribute.nil?
719
+
720
+ value = attribute.fetch("value")
721
+ payload = JSON.parse(value.fetch("stringValue"))
722
+ value["stringValue"] = Serialize.safe_generate(payload)
723
+ encoded = JSON.generate(carrier)
616
724
  EncodedSpan.new(encoded, encoded.bytesize)
725
+ rescue KeyError, JSON::ParserError, TypeError
726
+ nil
617
727
  end
618
728
 
619
729
  def request_envelope(first)
@@ -650,40 +760,6 @@ module Bitfab
650
760
  result
651
761
  end
652
762
 
653
- def span_name(operation, payload)
654
- raw_span = payload["rawSpan"]
655
- if operation == "external_span" && raw_span.is_a?(Hash)
656
- name = raw_span.dig("span_data", "name")
657
- return name if name.is_a?(String)
658
- end
659
- return payload["traceFunctionKey"] if payload["traceFunctionKey"].is_a?(String)
660
-
661
- "bitfab.#{operation}"
662
- end
663
-
664
- def timestamp(payload, field)
665
- raw = payload.dig("rawSpan", field) if payload["rawSpan"].is_a?(Hash)
666
- if raw.nil?
667
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
668
- raw = raw_trace[field] if raw_trace.is_a?(Hash)
669
- end
670
- return Time.now unless raw.is_a?(String)
671
-
672
- begin
673
- Time.iso8601(raw)
674
- rescue ArgumentError
675
- Time.now
676
- end
677
- end
678
-
679
- def error?(payload)
680
- raw_span = payload["rawSpan"]
681
- return true if raw_span.is_a?(Hash) && !raw_span.dig("span_data", "error").nil?
682
-
683
- errors = payload["errors"]
684
- errors.is_a?(Array) && !errors.empty?
685
- end
686
-
687
763
  private
688
764
 
689
765
  def status(span_status)
@@ -19,10 +19,12 @@ module Bitfab
19
19
  # on a body sized exactly to a 2.4 MB cap, prose produced a 2.4 MB carrier but
20
20
  # backslash-dense content produced 4.8 MB, which the exporter dropped.
21
21
  #
22
- # 2.8 MB leaves room beneath the 3 MB request ceiling for the span and request
23
- # envelopes wrapped around the attribute.
22
+ # The normal 2.8 MB fallback leaves room beneath the 3 MB wire target. Trace
23
+ # transport may first preserve a carrier up to 7.8 MB when its single-span
24
+ # request compresses below that target and stays under the 8 MB raw ceiling.
24
25
  module PayloadBudget
25
26
  MAX_SPAN_CARRIER_BYTES = 2_800_000
27
+ MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES = 7_800_000
26
28
 
27
29
  # Span fields that identify the span rather than carry user data. Trimming
28
30
  # one would leave a span that no longer says what it is, so they stay
@@ -47,23 +49,23 @@ module Bitfab
47
49
  # can at most double it, so anything under half the budget always fits and
48
50
  # anything past the budget never does. Ordinary spans settle on the first
49
51
  # comparison and never pay for the scan.
50
- def fits_carrier_budget?(body)
52
+ def fits_carrier_budget?(body, max_bytes = MAX_SPAN_CARRIER_BYTES)
51
53
  size = body.bytesize
52
- return true if size * 2 + 2 <= MAX_SPAN_CARRIER_BYTES
53
- return false if size + 2 > MAX_SPAN_CARRIER_BYTES
54
+ return true if size * 2 + 2 <= max_bytes
55
+ return false if size + 2 > max_bytes
54
56
 
55
- carrier_byte_length(body) <= MAX_SPAN_CARRIER_BYTES
57
+ carrier_byte_length(body) <= max_bytes
56
58
  end
57
59
 
58
60
  # Return a body within the budget, plus the fields that had to be stubbed.
59
- def enforce(payload, body, &encode)
60
- return [body, []] if fits_carrier_budget?(body)
61
+ def enforce(payload, body, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
62
+ return [body, []] if fits_carrier_budget?(body, max_bytes)
61
63
  return [body, []] unless payload.is_a?(Hash)
62
64
 
63
- trimmed_payload, trimmed = trim(payload, &encode)
65
+ trimmed_payload, trimmed = trim(payload, max_bytes, &encode)
64
66
  return [body, []] if trimmed_payload.nil?
65
67
 
66
- mark_trimmed(trimmed_payload, trimmed)
68
+ mark_trimmed(trimmed_payload, trimmed, max_bytes)
67
69
  [encode.call(trimmed_payload), trimmed]
68
70
  rescue
69
71
  [body, []]
@@ -73,7 +75,7 @@ module Bitfab
73
75
  # Returns [trimmed_payload, trimmed_keys], or nil when nothing could be
74
76
  # trimmed: the caller then ships the oversized body and lets the exporter
75
77
  # report the drop, which still beats silently emptying a span.
76
- def trim(payload, &encode)
78
+ def trim(payload, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
77
79
  copy, containers = clone_trimmable(payload)
78
80
  candidates = collect_candidates(containers)
79
81
  return nil if candidates.empty?
@@ -83,7 +85,7 @@ module Bitfab
83
85
  container[key] = "<unserializable: too_large_#{size}_bytes>"
84
86
  trimmed << key
85
87
  body = encode.call(copy)
86
- return [copy, trimmed] if fits_carrier_budget?(body)
88
+ return [copy, trimmed] if fits_carrier_budget?(body, max_bytes)
87
89
  end
88
90
  nil
89
91
  end
@@ -135,7 +137,7 @@ module Bitfab
135
137
 
136
138
  # Record the trim in the payload's own errors, which is what the server
137
139
  # reads to flag a trace as incomplete.
138
- def mark_trimmed(payload, trimmed)
140
+ def mark_trimmed(payload, trimmed, max_bytes)
139
141
  key = payload.key?(:errors) ? :errors : "errors"
140
142
  existing = payload[key]
141
143
  errors = existing.is_a?(Array) ? existing.dup : []
@@ -143,7 +145,7 @@ module Bitfab
143
145
  "source" => "sdk",
144
146
  "step" => "payload_budget",
145
147
  "error" => "trimmed oversized field(s) to fit the " \
146
- "#{MAX_SPAN_CARRIER_BYTES}-byte span carrier budget: " \
148
+ "#{max_bytes}-byte span carrier budget: " \
147
149
  "#{trimmed.uniq.join(", ")}"
148
150
  }
149
151
  payload[key] = errors
data/lib/bitfab/replay.rb CHANGED
@@ -865,7 +865,8 @@ module Bitfab
865
865
  adapt_ctx:,
866
866
  db_branch_lease: lease,
867
867
  source_bitfab_trace_id: original_trace_id,
868
- db_snapshot_ref:
868
+ db_snapshot_ref:,
869
+ http_client:
869
870
  )
870
871
  rescue => e
871
872
  warn "Bitfab: replay item for span #{original_span_id} failed before execution: #{e.message}"
@@ -1009,19 +1010,33 @@ module Bitfab
1009
1010
 
1010
1011
  # Block until the server has persisted every replay trace this run queued.
1011
1012
  #
1012
- # Flushing only proves the batches left the process. The server writes a
1013
- # trace's spans and completion independently, so the barrier also polls
1014
- # replay status with the span count each trace owes: a trace is ready only
1015
- # once its completion and all of its spans have landed.
1013
+ # In the normal case the verdict is local: ingestion commits a request's
1014
+ # carriers before it answers, so a flush that delivered every carrier has
1015
+ # already proven the traces are whole and polling only asked the server to
1016
+ # repeat itself. Acks cannot settle a request that timed out client-side
1017
+ # after the server committed, so anything short of fully delivered falls
1018
+ # through to the server poll, which stays the authority there.
1016
1019
  def wait_for_replay_persistence(http_client, test_run_id, sdk_trace_ids)
1017
- expected_span_counts = Transport.take_replay_span_counts(sdk_trace_ids.compact)
1018
- return if expected_span_counts.empty?
1020
+ trace_ids = sdk_trace_ids.compact
1019
1021
 
1020
- unless Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS)
1021
- raise "Replay traces could not be flushed before the delivery deadline " \
1022
- "(test_run_id #{test_run_id})."
1022
+ # Checked before flushing: a run whose traces never closed submitted
1023
+ # nothing to wait on, and must not emit an export request to discover it.
1024
+ unless http_client.closed_deliveries?(trace_ids)
1025
+ http_client.take_trace_deliveries(trace_ids)
1026
+ return
1023
1027
  end
1024
1028
 
1029
+ # A failed flush is a hint, not a verdict. It means delivery was not
1030
+ # CONFIRMED within the deadline, which is not the same as lost: the export
1031
+ # timeout can fire while requests are still in flight and the server goes
1032
+ # on to persist every one of them. Failing here failed runs whose data had
1033
+ # fully landed.
1034
+ flushed = Bitfab.flush_traces(timeout: PERSISTENCE_TIMEOUT_SECONDS)
1035
+
1036
+ deliveries = http_client.take_trace_deliveries(trace_ids).select { |_id, report| report.closed }
1037
+ expected_span_counts = deliveries.transform_values(&:span_count)
1038
+ return if expected_span_counts.empty? || deliveries.each_value.all?(&:delivered)
1039
+
1025
1040
  deadline = Otel.monotonic_now + PERSISTENCE_TIMEOUT_SECONDS
1026
1041
  missing = expected_span_counts.keys
1027
1042
 
@@ -1036,9 +1051,15 @@ module Bitfab
1036
1051
  sleep((deadline - Otel.monotonic_now).clamp(0, PERSISTENCE_POLL_SECONDS))
1037
1052
  end
1038
1053
 
1054
+ cause = if flushed
1055
+ ""
1056
+ else
1057
+ " Delivery was also not confirmed before the flush deadline, so the " \
1058
+ "spans likely never reached the server."
1059
+ end
1039
1060
  raise "Replay traces were not fully persisted before the delivery deadline " \
1040
1061
  "(test_run_id #{test_run_id}, missing #{missing.length} of " \
1041
- "#{expected_span_counts.length} trace(s))."
1062
+ "#{expected_span_counts.length} trace(s)).#{cause}"
1042
1063
  end
1043
1064
 
1044
1065
  # Normalize a complete-replay tokens hash (string-keyed JSON) into the
@@ -1059,7 +1080,7 @@ module Bitfab
1059
1080
  def execute_item(item, receiver, method_name, test_run_id, input_source_span_id = nil, metrics = {},
1060
1081
  input_source_trace_id: nil, mock_strategy: "marked", mock_tree: nil, mock_overrides: nil,
1061
1082
  fetch_span_output: nil, adapt_inputs: nil, adapt_ctx: nil, db_branch_lease: nil, source_bitfab_trace_id: nil,
1062
- db_snapshot_ref: nil)
1083
+ db_snapshot_ref: nil, http_client: nil)
1063
1084
  args, kwargs = Serialize.deserialize_inputs(item)
1064
1085
 
1065
1086
  fn_result = nil
@@ -1070,6 +1091,10 @@ module Bitfab
1070
1091
  # complete-replay loop). Carried on the item under :_sdk_trace_id, never
1071
1092
  # surfaced as the public :trace_id.
1072
1093
  sdk_trace_id = SecureRandom.uuid
1094
+ # Declared before this item's first span is submitted: the transport
1095
+ # records delivery only for traces someone asked about, so anything
1096
+ # submitted before this would go untracked.
1097
+ http_client&.track_trace_deliveries([sdk_trace_id])
1073
1098
 
1074
1099
  begin
1075
1100
  ReplayContext.with_context(
@@ -14,7 +14,7 @@ module Bitfab
14
14
  # further. It is deliberately the same number as the whole-span budget: one
15
15
  # legitimately large value may use the entire budget, and PayloadBudget is
16
16
  # what enforces the total once every field is in.
17
- MAX_SERIALIZED_BYTES = PayloadBudget::MAX_SPAN_CARRIER_BYTES
17
+ MAX_SERIALIZED_BYTES = PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
18
18
 
19
19
  # Recursion guard for cyclic graphs and pathologically nested structures.
20
20
  MAX_SERIALIZE_DEPTH = 16
@@ -260,18 +260,20 @@ module Bitfab
260
260
  # raises and stubs strays) instead of letting JSON.generate raise and drop
261
261
  # the whole span/trace silently. A degraded payload warns loudly so the
262
262
  # trace isn't quietly left incomplete or not replayable.
263
- def safe_generate(payload)
263
+ def safe_generate(payload, max_carrier_bytes: PayloadBudget::MAX_SPAN_CARRIER_BYTES)
264
264
  # Budget the value that was actually encoded, not the caller's original: a
265
265
  # cyclic or otherwise non-encodable field cannot be sized (JSON.generate
266
266
  # raises on it), so on the original graph the biggest field is skipped as
267
267
  # a trim candidate and the oversized body ships anyway. The sanitized copy
268
268
  # has those values already replaced with stubs, so every field is sizeable.
269
269
  body, encoded = encode_unbounded(payload)
270
- body, trimmed = PayloadBudget.enforce(encoded, body) { |value| encode_unbounded(value).first }
270
+ body, trimmed = PayloadBudget.enforce(encoded, body, max_carrier_bytes) do |value|
271
+ encode_unbounded(value).first
272
+ end
271
273
  if trimmed.any?
272
274
  Bitfab.warn_once(
273
275
  "payload-over-budget",
274
- "a span payload exceeded the #{PayloadBudget::MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its " \
276
+ "a span payload exceeded the #{max_carrier_bytes}-byte carrier budget; its " \
275
277
  "largest field(s) (#{trimmed.uniq.join(", ")}) were replaced with placeholders " \
276
278
  "so the span still ships. The span is incomplete and may not be replayable."
277
279
  )
@@ -7,8 +7,8 @@ module Bitfab
7
7
  # mechanism, so callers never depend on OpenTelemetry directly.
8
8
  module Transport
9
9
  class << self
10
- def create_trace_transport(direct_sender:)
11
- Otel.create_transport(direct_sender:)
10
+ def create_trace_transport(direct_sender:, on_delivered: nil)
11
+ Otel.create_transport(direct_sender:, on_delivered:)
12
12
  end
13
13
 
14
14
  def flush_trace_transports(timeout = 30.0)
@@ -18,10 +18,6 @@ module Bitfab
18
18
  def shutdown_trace_transports(timeout = 30.0)
19
19
  Otel.shutdown_transports(timeout)
20
20
  end
21
-
22
- def take_replay_span_counts(trace_ids)
23
- Otel.take_replay_span_counts(trace_ids)
24
- end
25
21
  end
26
22
  end
27
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.4"
4
+ VERSION = "0.36.6"
5
5
  end
@@ -27,6 +27,16 @@ module Bitfab
27
27
  end
28
28
  end
29
29
 
30
+ # Every occurrence, not one per key. Reserved for the export path, where
31
+ # the count IS the signal: suppressing repeats makes a sustained outage
32
+ # look like a single blip, and TypeScript, Python and Go all report each
33
+ # dropped batch.
34
+ def warn_always(message)
35
+ warn "Bitfab: #{message}"
36
+ rescue Exception # rubocop:disable Lint/RescueException
37
+ # Logging must never crash the host app (e.g. a closed $stderr).
38
+ end
39
+
30
40
  # Test-only: clear the dedup set so a warning can fire again.
31
41
  def _reset_warn_once
32
42
  @warn_once_mutex.synchronize { @warn_once_seen.clear }
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.36.4
4
+ version: 0.36.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team