bitfab 0.36.5 → 0.36.7

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: 49b714ee6d468fc34c0c6009802c5736d33f03275f06d7d7148635b5d70b7caa
4
- data.tar.gz: 3816843ddea8722c0222cfc0bed5c65ea05f5abb5ff764233e5ab302d609f2d2
3
+ metadata.gz: f18189ba0b9b7a2c8cd23ee6672f927af7f9f6400457d71d86b90c13a61698e9
4
+ data.tar.gz: 61f38c9eb03166eeef759aca2914a015e92fe89247c163541c786331ed8fa26d
5
5
  SHA512:
6
- metadata.gz: 1f5504e7d7622ccf55145b9124ee0d8ca42f774bdaea720a92382af42148bbf16afb72708d673fb359b37a9840364a770e4a1a87003f016aff2ebd1a59a52b38
7
- data.tar.gz: db5fbc39adf358e640ae315348eb15ceb5798c67747d092eaaf37651cf9a89542ae9c4d8899a44ebfd4425ba623598e9e5af382dec11f119a917d52cf9ccb0dd
6
+ metadata.gz: 8940e1f95426c3fa3343d48a340b3b4b2d2adec10c714b9e204ac7fdcfa348fba28b6bf4ed1820c1acd67536883a21701ccf401bc547cf2c3bd4c9b929169f6f
7
+ data.tar.gz: 88935462030c7997ab153444e327666cc0040a0baed397c19322bfd1cedff2c9ef6624b64ce227aba02cad471d6ad05c639b1cdfbdb6a69f3a6e2f1346dca395
@@ -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
@@ -108,7 +123,11 @@ module Bitfab
108
123
 
109
124
  # Queue an external span on this client's trace transport (fire-and-forget).
110
125
  def send_external_span(payload)
111
- 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
+ )
112
131
  end
113
132
 
114
133
  # Make a GET request to the Bitfab API.
@@ -254,7 +273,15 @@ module Bitfab
254
273
 
255
274
  # Queue an external trace on this client's trace transport (fire-and-forget).
256
275
  def send_external_trace(payload)
257
- 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
+ )
258
285
  end
259
286
 
260
287
  # Read the replay traces the server has fully persisted so far.
@@ -268,6 +295,75 @@ module Bitfab
268
295
 
269
296
  private
270
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
+
271
367
  # Returns nil once the client is closed. A stray traced call after close
272
368
  # must not crash the host app, and raising here would be swallowed by the
273
369
  # span finalize path, dropping the span with no explanation.
@@ -283,13 +379,184 @@ module Bitfab
283
379
  end
284
380
 
285
381
  @transport ||= Transport.create_trace_transport(
286
- direct_sender: method(:send_transport_request)
382
+ direct_sender: method(:deliver_carriers),
383
+ on_delivered: method(:record_delivered_carriers)
287
384
  )
288
385
  end
289
386
  end
290
387
 
291
- def send_transport_request(endpoint, request, timeout)
292
- send_prepared(endpoint, request, timeout:, max_retries: 1)
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)
413
+ )
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
516
+ end
517
+
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
293
560
  end
294
561
 
295
562
  # Normalize each entry to a hash with stable string keys, accepting either
data/lib/bitfab/otel.rb CHANGED
@@ -19,7 +19,7 @@ module Bitfab
19
19
  module Otel
20
20
  OPERATION_ATTRIBUTE = "bitfab.operation"
21
21
  PAYLOAD_ATTRIBUTE = "bitfab.payload"
22
- OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces"
22
+ CARRIER_REF_IVAR = :@bitfab_carrier_ref
23
23
  MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
24
24
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
25
25
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
@@ -32,7 +32,12 @@ module Bitfab
32
32
  SCHEDULE_DELAY_MILLIS = 5_000
33
33
  EXPORT_TIMEOUT_MILLIS = 30_000
34
34
  EXPORT_RETRIES = 3
35
- RETRY_DELAY_SECONDS = 0.1
35
+ RETRY_BASE_DELAY_MILLIS = 100
36
+ # Ceiling on the exponential growth of our OWN backoff. It does not bound a
37
+ # wait the server asked for: OTLP says to honor Retry-After, and calls data
38
+ # dropped while throttled the outcome to avoid. What bounds an honored wait
39
+ # is the export budget, since the processor kills a longer export.
40
+ RETRY_BACKOFF_CEILING_MILLIS = 5_000
36
41
 
37
42
  # The comma that joins adjacent spans in the request's span list.
38
43
  SPAN_SEPARATOR_BYTES = 1
@@ -44,7 +49,22 @@ module Bitfab
44
49
  # Encoding once and remembering the size is what keeps request packing
45
50
  # linear: sizing a candidate batch by re-encoding the whole request
46
51
  # re-escapes every carrier's bitfab.payload string on every span considered.
47
- EncodedSpan = Struct.new(:encoded, :size)
52
+ EncodedSpan = Struct.new(:encoded, :size, :ref)
53
+
54
+ # Which carrier a payload is, for delivery accounting only. Supplied by the
55
+ # caller that built the payload: the transport never reads inside one.
56
+ #
57
+ # span_id is nil for the carrier that closes a trace, which is what tells
58
+ # the transport the trace's expected set has stopped growing.
59
+ CarrierRef = Struct.new(:trace_id, :span_id, keyword_init: true)
60
+
61
+ # Everything the transport needs to know ABOUT a payload without reading
62
+ # one. Supplied by the caller that built it; each field falls back to
63
+ # something the transport can decide without looking inside.
64
+ #
65
+ # ref is nil for carriers nobody accounts for, name defaults to
66
+ # "bitfab.<operation>", nil timestamps let OTel stamp the carrier itself.
67
+ CarrierMeta = Struct.new(:ref, :name, :started_at, :ended_at, :errored, keyword_init: true)
48
68
 
49
69
  # The invariant head and tail of an OTLP request for one export window. Key
50
70
  # order matches what JSON.generate emits for the equivalent Hash, so a body
@@ -63,8 +83,21 @@ module Bitfab
63
83
 
64
84
  INVALID_SPAN_ID = ("\0" * 8).b
65
85
 
66
- PayloadTooLargeError = Class.new(StandardError)
67
- PartialSuccessError = Class.new(StandardError)
86
+ # Why a delivery failed, in the only two terms the transport acts on. A
87
+ # sender classifies everything it can see, including a network fault
88
+ # carrying no verdict; anything else reaching the transport is a fault in
89
+ # the sender and is not retried.
90
+ class DeliveryError < StandardError
91
+ attr_reader :retryable, :oversized, :retry_after_ms
92
+
93
+ def initialize(message, retryable: false, oversized: false, retry_after_ms: nil)
94
+ super(message)
95
+ @retryable = retryable
96
+ @oversized = oversized
97
+ # How long the server asked us to wait, when it said so.
98
+ @retry_after_ms = retry_after_ms
99
+ end
100
+ end
68
101
 
69
102
  class << self
70
103
  def max_request_bytes_from_env
@@ -97,9 +130,10 @@ module Bitfab
97
130
  DEFAULT_EXPORT_CONCURRENCY
98
131
  end
99
132
 
100
- def create_transport(direct_sender:)
133
+ def create_transport(direct_sender:, on_delivered: nil)
101
134
  BatchTransport.new(
102
135
  direct_sender:,
136
+ on_delivered:,
103
137
  export_concurrency: export_concurrency_from_env,
104
138
  max_request_bytes: max_request_bytes_from_env
105
139
  )
@@ -131,40 +165,6 @@ module Bitfab
131
165
  end
132
166
  end
133
167
 
134
- # Count the spans submitted for each replay trace and forget them, so the
135
- # replay barrier knows how many spans the server must have persisted
136
- # before the run can be finalized.
137
- def take_replay_span_counts(trace_ids)
138
- ensure_process_state
139
- submission_mutex.synchronize do
140
- counts = trace_ids.each_with_object({}) do |trace_id, acc|
141
- next unless replay_trace_submissions.include?(trace_id)
142
-
143
- acc[trace_id] = (trace_submission_span_ids.delete(trace_id) || Set.new).size
144
- end
145
- replay_trace_submissions.subtract(trace_ids)
146
- counts
147
- end
148
- end
149
-
150
- def record_submission(operation, payload)
151
- ensure_process_state
152
- source_trace_id = payload["sourceTraceId"]
153
- unless source_trace_id.is_a?(String)
154
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
155
- source_trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
156
- end
157
- return unless source_trace_id.is_a?(String)
158
-
159
- submission_mutex.synchronize do
160
- if operation == "external_span"
161
- record_span_submission(source_trace_id, payload)
162
- elsif payload["completed"] == true
163
- record_trace_completion(source_trace_id, payload)
164
- end
165
- end
166
- end
167
-
168
168
  def monotonic_now
169
169
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
170
  end
@@ -181,31 +181,12 @@ module Bitfab
181
181
  reset_state_after_fork
182
182
  end
183
183
 
184
- # Replay submission counts belong to the parent's run, never the child's.
185
184
  def reset_state_after_fork
186
185
  @state_pid = Process.pid
187
- @trace_submission_span_ids = {}
188
- @replay_trace_submissions = Set.new
189
186
  end
190
187
 
191
188
  private
192
189
 
193
- def record_span_submission(source_trace_id, payload)
194
- raw_span = payload["rawSpan"]
195
- span_id = raw_span["id"] if raw_span.is_a?(Hash)
196
- span_id = "submission-#{monotonic_now}" unless span_id.is_a?(String)
197
- (trace_submission_span_ids[source_trace_id] ||= Set.new) << span_id
198
- end
199
-
200
- def record_trace_completion(source_trace_id, payload)
201
- if payload["testRunId"].is_a?(String)
202
- replay_trace_submissions << source_trace_id
203
- trace_submission_span_ids[source_trace_id] ||= Set.new
204
- else
205
- trace_submission_span_ids.delete(source_trace_id)
206
- end
207
- end
208
-
209
190
  def current_process_transports
210
191
  live_transports_mutex.synchronize { live_transports.select { |t| t.owner_pid == Process.pid } }
211
192
  end
@@ -217,27 +198,21 @@ module Bitfab
217
198
  def live_transports_mutex
218
199
  @live_transports_mutex ||= Mutex.new
219
200
  end
220
-
221
- def submission_mutex
222
- @submission_mutex ||= Mutex.new
223
- end
224
-
225
- def trace_submission_span_ids
226
- @trace_submission_span_ids ||= {}
227
- end
228
-
229
- def replay_trace_submissions
230
- @replay_trace_submissions ||= Set.new
231
- end
232
201
  end
233
202
 
234
203
  # Encodes carrier spans as OTLP/JSON and posts them straight to Bitfab.
235
204
  class DirectExporter
236
- def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:)
205
+ def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:, on_delivered: nil)
237
206
  @direct_sender = direct_sender
207
+ @on_delivered = on_delivered
238
208
  @max_request_bytes = max_request_bytes
239
209
  @max_request_batch_size = max_request_batch_size
240
210
  @export_concurrency = export_concurrency
211
+ # Eager, unlike the module-level mutexes: this one is first touched by
212
+ # the export threads fanning out a batch, and `||=` racing between them
213
+ # would hand each a different mutex and no mutual exclusion at all.
214
+ @throttle_mutex = Mutex.new
215
+ @throttled_until = 0.0
241
216
  end
242
217
 
243
218
  def export(spans, timeout: nil)
@@ -324,6 +299,10 @@ module Bitfab
324
299
  prepared = Compress.prepare_request_body(Encoder.encode_request(envelope, request_spans))
325
300
  if prepared.wire_bytes <= @max_request_bytes
326
301
  send_with_retries(prepared)
302
+ # Refs come from the original spans: trimming rebuilds a span
303
+ # without one, and a trimmed carrier still reached the server
304
+ # under its own identity.
305
+ report_delivered(batch.spans)
327
306
  return true
328
307
  end
329
308
  end
@@ -345,72 +324,132 @@ module Bitfab
345
324
  request_raw_bytes = envelope.size + trimmed.size
346
325
  already_trimmed = true
347
326
  end
348
- rescue PayloadTooLargeError
349
- warn_oversized(batch.spans.size)
350
- false
351
- rescue PartialSuccessError
327
+ rescue DeliveryError => e
328
+ # Returned, never re-raised: a raise here escapes the export worker and
329
+ # aborts the whole batch fan-out at join, where TypeScript and Python
330
+ # record a failed export and let sibling batches finish.
331
+ if e.oversized
332
+ warn_oversized(batch.spans.size)
333
+ else
334
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
335
+ end
352
336
  false
353
337
  rescue => e
354
- Bitfab.warn_once(
355
- "otel-export-failed",
356
- "failed to export an OpenTelemetry span batch (further occurrences " \
357
- "suppressed): #{e.message}"
358
- )
338
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
359
339
  false
360
340
  end
361
341
 
362
342
  def warn_oversized(span_count)
363
343
  subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
364
- Bitfab.warn_once(
365
- "otel-payload-too-large",
366
- "#{subject} exceeded the ingestion request limit and could not be exported"
367
- )
344
+ Bitfab.warn_always("#{subject} exceeded the ingestion request limit and could not be exported")
368
345
  end
369
346
 
370
347
  def send_with_retries(request)
371
348
  attempt = 0
349
+ # One budget for the whole exchange, waits included: the processor kills
350
+ # the export at this deadline, so a wait past it cannot be served.
351
+ deadline = Otel.monotonic_now + EXPORT_TIMEOUT_MILLIS / 1000.0
372
352
  begin
373
353
  attempt += 1
374
- response = @direct_sender.call(OTLP_TRACES_ENDPOINT, request, EXPORT_TIMEOUT_MILLIS / 1000.0)
375
- check_partial_success(response)
376
- rescue PartialSuccessError
377
- raise
354
+ await_throttle(deadline)
355
+ @direct_sender.call(request, [0, deadline - Otel.monotonic_now].max)
378
356
  rescue => e
379
- raise PayloadTooLargeError if response_status(e) == 413
357
+ record_throttle(e)
358
+ raise if oversized?(e)
380
359
  raise if attempt >= EXPORT_RETRIES || !retryable?(e)
381
360
 
382
- sleep(RETRY_DELAY_SECONDS)
361
+ wait = retry_wait_seconds(e, attempt - 1, deadline - Otel.monotonic_now)
362
+ raise if wait.nil?
363
+
364
+ sleep(wait)
383
365
  retry
384
366
  end
385
367
  end
386
368
 
387
- def check_partial_success(response)
388
- partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
389
- return unless partial_success.is_a?(Hash)
369
+ # Announce the carriers a request delivered. Wrapped because a listener
370
+ # that raises must never turn a delivered batch into a failed export.
371
+ def report_delivered(spans)
372
+ return if @on_delivered.nil?
390
373
 
391
- rejected = partial_success["rejectedSpans"]
392
- return if rejected.nil? || rejected.to_s == "0"
374
+ refs = spans.filter_map(&:ref)
375
+ return if refs.empty?
393
376
 
394
- Bitfab.warn_once(
395
- "otel-partial-success",
396
- "OTLP ingestion rejected #{rejected} span(s): " \
397
- "#{partial_success["errorMessage"] || "no reason provided"}"
398
- )
399
- raise PartialSuccessError
377
+ begin
378
+ @on_delivered.call(refs)
379
+ rescue => e
380
+ Bitfab.warn_once("otel-delivery-listener-failed", "a delivery listener raised: #{e.message}")
381
+ end
400
382
  end
401
383
 
402
- def response_status(error)
403
- return nil unless error.respond_to?(:response)
384
+ # Only what the sender classified. Anything else reaching here is a fault
385
+ # in the sender itself, and retrying a deterministic bug just delays it.
386
+ def retryable?(error)
387
+ error.is_a?(DeliveryError) && error.retryable
388
+ end
389
+
390
+ def oversized?(error)
391
+ error.is_a?(DeliveryError) && error.oversized
392
+ end
393
+
394
+ # How long to wait before the next send attempt, or nil to stop trying.
395
+ #
396
+ # A server that sent Retry-After has told us when it wants us back, so
397
+ # that wait is honored exactly. Clamping it would return early, which is
398
+ # the single thing the server asked us not to do; when the wait is longer
399
+ # than we are willing to hold a batch, the honest answer is to give up
400
+ # rather than come back sooner and add load to something already
401
+ # struggling.
402
+ #
403
+ # Absent an instruction, back off exponentially so a struggling server is
404
+ # not hit on a fixed cadence, and jitter it so every client in a fleet
405
+ # does not return in lockstep.
406
+ # Half the budget, not all of it: a wait is only worth taking if what is
407
+ # left afterwards can still carry the request. Spending the whole budget
408
+ # waiting means being killed mid-wait, losing the batch anyway.
409
+ def retry_wait_seconds(error, attempt, remaining_seconds)
410
+ affordable = remaining_seconds / 2.0
411
+ requested = error.is_a?(DeliveryError) ? error.retry_after_ms : nil
412
+ if requested
413
+ return nil unless requested / 1000.0 < affordable
414
+
415
+ return requested / 1000.0
416
+ end
404
417
 
405
- response = error.response
406
- response.respond_to?(:code) ? response.code.to_i : nil
418
+ backoff = [RETRY_BASE_DELAY_MILLIS * (2**attempt), RETRY_BACKOFF_CEILING_MILLIS].min
419
+ jittered = (backoff / 2.0 + rand * (backoff / 2.0)) / 1000.0
420
+ (jittered < affordable) ? jittered : nil
407
421
  end
408
422
 
409
- def retryable?(error)
410
- status = response_status(error)
411
- return true if status.nil?
423
+ # Remember a throttle the server asked for, so the requests fanned out
424
+ # alongside this one respect it too. Delaying only the request that was
425
+ # refused leaves the others in the window hitting a server that just asked
426
+ # for room.
427
+ def record_throttle(error)
428
+ requested = error.is_a?(DeliveryError) ? error.retry_after_ms : nil
429
+ return unless requested
412
430
 
413
- status >= 500 || [408, 425, 429].include?(status)
431
+ @throttle_mutex.synchronize do
432
+ @throttled_until = [@throttled_until.to_f, Otel.monotonic_now + requested / 1000.0].max
433
+ end
434
+ end
435
+
436
+ # Waits out an active throttle, or reports the batch undeliverable when
437
+ # the throttle outlasts what we are willing to hold it for. Either way
438
+ # nothing is sent while the server has asked us to stay away.
439
+ def await_throttle(deadline)
440
+ remaining = @throttle_mutex.synchronize { @throttled_until.to_f - Otel.monotonic_now }
441
+ return if remaining <= 0
442
+
443
+ # Waited out, not refused: OTLP asks the client to hold off until the
444
+ # window passes. Only a throttle outliving what the budget can serve is
445
+ # refused, because the processor would kill the wait before it sent.
446
+ if remaining >= (deadline - Otel.monotonic_now) / 2.0
447
+ raise DeliveryError.new(
448
+ "OTLP ingestion is throttled for another #{remaining.round(1)}s, longer than the export budget"
449
+ )
450
+ end
451
+
452
+ sleep(remaining)
414
453
  end
415
454
  end
416
455
 
@@ -458,10 +497,11 @@ module Bitfab
458
497
 
459
498
  def initialize(direct_sender:, max_export_batch_size: nil,
460
499
  max_request_batch_size: DIRECT_MAX_REQUEST_BATCH_SIZE, max_queue_size: MAX_QUEUE_SIZE,
461
- export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil)
500
+ export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil, on_delivered: nil)
462
501
  raise ArgumentError, "max_request_batch_size must be a positive integer" unless max_request_batch_size.positive?
463
502
 
464
503
  @direct_sender = direct_sender
504
+ @on_delivered = on_delivered
465
505
  @max_export_batch_size = max_export_batch_size || DIRECT_MAX_EXPORT_BATCH_SIZE
466
506
  @max_request_batch_size = max_request_batch_size
467
507
  @max_queue_size = max_queue_size
@@ -475,18 +515,19 @@ module Bitfab
475
515
  Otel.register_transport(self)
476
516
  end
477
517
 
478
- def submit(operation, payload)
479
- Otel.record_submission(operation, payload)
518
+ def submit(operation, payload, meta = nil)
519
+ meta ||= CarrierMeta.new
480
520
  # Encoding is the expensive part of a submit and needs no mutual
481
521
  # exclusion, so it stays outside the lock.
482
- name = Encoder.span_name(operation, payload)
522
+ name = meta.name || "bitfab.#{operation}"
483
523
  encoded_payload = Serialize.safe_generate(
484
524
  payload,
485
525
  max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
486
526
  )
487
- started_at = Encoder.timestamp(payload, "started_at")
488
- ended_at = Encoder.timestamp(payload, "ended_at")
489
- errored = Encoder.error?(payload)
527
+ encoded_payload.instance_variable_set(CARRIER_REF_IVAR, meta.ref) if meta.ref
528
+ started_at = meta.started_at || Time.now
529
+ ended_at = meta.ended_at || Time.now
530
+ errored = meta.errored
490
531
 
491
532
  # Pipeline construction and the closed check are serialized: concurrent
492
533
  # first submits in a forked child would otherwise each build a pipeline,
@@ -630,7 +671,8 @@ module Bitfab
630
671
  direct_sender: @direct_sender,
631
672
  max_request_bytes: @max_request_bytes,
632
673
  max_request_batch_size: @max_request_batch_size,
633
- export_concurrency: @export_concurrency
674
+ export_concurrency: @export_concurrency,
675
+ on_delivered: @on_delivered
634
676
  )
635
677
  end
636
678
  end
@@ -640,7 +682,9 @@ module Bitfab
640
682
  class << self
641
683
  def encode_span(span)
642
684
  encoded = JSON.generate(span_to_otlp(span))
643
- EncodedSpan.new(encoded, encoded.bytesize)
685
+ payload = span.attributes&.fetch(PAYLOAD_ATTRIBUTE, nil)
686
+ ref = payload&.instance_variable_get(CARRIER_REF_IVAR)
687
+ EncodedSpan.new(encoded, encoded.bytesize, ref)
644
688
  end
645
689
 
646
690
  def trim_span(span)
@@ -691,40 +735,6 @@ module Bitfab
691
735
  result
692
736
  end
693
737
 
694
- def span_name(operation, payload)
695
- raw_span = payload["rawSpan"]
696
- if operation == "external_span" && raw_span.is_a?(Hash)
697
- name = raw_span.dig("span_data", "name")
698
- return name if name.is_a?(String)
699
- end
700
- return payload["traceFunctionKey"] if payload["traceFunctionKey"].is_a?(String)
701
-
702
- "bitfab.#{operation}"
703
- end
704
-
705
- def timestamp(payload, field)
706
- raw = payload.dig("rawSpan", field) if payload["rawSpan"].is_a?(Hash)
707
- if raw.nil?
708
- raw_trace = payload["externalTrace"] || payload["rawTrace"]
709
- raw = raw_trace[field] if raw_trace.is_a?(Hash)
710
- end
711
- return Time.now unless raw.is_a?(String)
712
-
713
- begin
714
- Time.iso8601(raw)
715
- rescue ArgumentError
716
- Time.now
717
- end
718
- end
719
-
720
- def error?(payload)
721
- raw_span = payload["rawSpan"]
722
- return true if raw_span.is_a?(Hash) && !raw_span.dig("span_data", "error").nil?
723
-
724
- errors = payload["errors"]
725
- errors.is_a?(Array) && !errors.empty?
726
- end
727
-
728
738
  private
729
739
 
730
740
  def status(span_status)
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(
@@ -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.5"
4
+ VERSION = "0.36.7"
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.5
4
+ version: 0.36.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team