bitfab 0.36.5 → 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: 49b714ee6d468fc34c0c6009802c5736d33f03275f06d7d7148635b5d70b7caa
4
- data.tar.gz: 3816843ddea8722c0222cfc0bed5c65ea05f5abb5ff764233e5ab302d609f2d2
3
+ metadata.gz: dd73e07889e4f74cd94c6ca534e423a7aa95e3ce6f1e1ba306e9fe33df8de8df
4
+ data.tar.gz: fb2eb3cd99066d8338f6ad03d18bc3a8de9f3955659aa66835c83066a95d3595
5
5
  SHA512:
6
- metadata.gz: 1f5504e7d7622ccf55145b9124ee0d8ca42f774bdaea720a92382af42148bbf16afb72708d673fb359b37a9840364a770e4a1a87003f016aff2ebd1a59a52b38
7
- data.tar.gz: db5fbc39adf358e640ae315348eb15ceb5798c67747d092eaaf37651cf9a89542ae9c4d8899a44ebfd4425ba623598e9e5af382dec11f119a917d52cf9ccb0dd
6
+ metadata.gz: b7155a3d0426c00f57668975485f2ba722bd3f0555dd897024f586290b36265f5851d49cc88242eb552421758b9d624b31e21edfb8290e68f693110e8b93cd32
7
+ data.tar.gz: 87ae26a27f8864294f431b80d87f0a7f768fdb2a2f09786929dad90d61316ce7c1106e75fe9271c63260cebe3545a2e73676e94295aa281f4d24c0d101184b0f
@@ -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,6 @@ 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"
23
22
  MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
24
23
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
25
24
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
@@ -32,7 +31,12 @@ module Bitfab
32
31
  SCHEDULE_DELAY_MILLIS = 5_000
33
32
  EXPORT_TIMEOUT_MILLIS = 30_000
34
33
  EXPORT_RETRIES = 3
35
- 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
36
40
 
37
41
  # The comma that joins adjacent spans in the request's span list.
38
42
  SPAN_SEPARATOR_BYTES = 1
@@ -44,7 +48,22 @@ module Bitfab
44
48
  # Encoding once and remembering the size is what keeps request packing
45
49
  # linear: sizing a candidate batch by re-encoding the whole request
46
50
  # re-escapes every carrier's bitfab.payload string on every span considered.
47
- 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)
48
67
 
49
68
  # The invariant head and tail of an OTLP request for one export window. Key
50
69
  # order matches what JSON.generate emits for the equivalent Hash, so a body
@@ -63,8 +82,21 @@ module Bitfab
63
82
 
64
83
  INVALID_SPAN_ID = ("\0" * 8).b
65
84
 
66
- PayloadTooLargeError = Class.new(StandardError)
67
- 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
68
100
 
69
101
  class << self
70
102
  def max_request_bytes_from_env
@@ -97,9 +129,10 @@ module Bitfab
97
129
  DEFAULT_EXPORT_CONCURRENCY
98
130
  end
99
131
 
100
- def create_transport(direct_sender:)
132
+ def create_transport(direct_sender:, on_delivered: nil)
101
133
  BatchTransport.new(
102
134
  direct_sender:,
135
+ on_delivered:,
103
136
  export_concurrency: export_concurrency_from_env,
104
137
  max_request_bytes: max_request_bytes_from_env
105
138
  )
@@ -131,38 +164,22 @@ module Bitfab
131
164
  end
132
165
  end
133
166
 
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)
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)
138
174
  ensure_process_state
139
175
  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
176
+ carrier_refs[span_id] = ref
177
+ carrier_refs.shift while carrier_refs.size > MAX_QUEUE_SIZE
147
178
  end
148
179
  end
149
180
 
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
181
+ def take_carrier_ref(span_id)
182
+ submission_mutex.synchronize { carrier_refs.delete(span_id) }
166
183
  end
167
184
 
168
185
  def monotonic_now
@@ -181,31 +198,14 @@ module Bitfab
181
198
  reset_state_after_fork
182
199
  end
183
200
 
184
- # 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.
185
202
  def reset_state_after_fork
186
203
  @state_pid = Process.pid
187
- @trace_submission_span_ids = {}
188
- @replay_trace_submissions = Set.new
204
+ @carrier_refs = {}
189
205
  end
190
206
 
191
207
  private
192
208
 
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
209
  def current_process_transports
210
210
  live_transports_mutex.synchronize { live_transports.select { |t| t.owner_pid == Process.pid } }
211
211
  end
@@ -222,22 +222,24 @@ module Bitfab
222
222
  @submission_mutex ||= Mutex.new
223
223
  end
224
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
225
+ def carrier_refs
226
+ @carrier_refs ||= {}
231
227
  end
232
228
  end
233
229
 
234
230
  # Encodes carrier spans as OTLP/JSON and posts them straight to Bitfab.
235
231
  class DirectExporter
236
- 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)
237
233
  @direct_sender = direct_sender
234
+ @on_delivered = on_delivered
238
235
  @max_request_bytes = max_request_bytes
239
236
  @max_request_batch_size = max_request_batch_size
240
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
241
243
  end
242
244
 
243
245
  def export(spans, timeout: nil)
@@ -324,6 +326,10 @@ module Bitfab
324
326
  prepared = Compress.prepare_request_body(Encoder.encode_request(envelope, request_spans))
325
327
  if prepared.wire_bytes <= @max_request_bytes
326
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)
327
333
  return true
328
334
  end
329
335
  end
@@ -345,72 +351,132 @@ module Bitfab
345
351
  request_raw_bytes = envelope.size + trimmed.size
346
352
  already_trimmed = true
347
353
  end
348
- rescue PayloadTooLargeError
349
- warn_oversized(batch.spans.size)
350
- false
351
- rescue PartialSuccessError
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
359
+ warn_oversized(batch.spans.size)
360
+ else
361
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
362
+ end
352
363
  false
353
364
  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
- )
365
+ Bitfab.warn_always("failed to export an OpenTelemetry span batch: #{e.message}")
359
366
  false
360
367
  end
361
368
 
362
369
  def warn_oversized(span_count)
363
370
  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
- )
371
+ Bitfab.warn_always("#{subject} exceeded the ingestion request limit and could not be exported")
368
372
  end
369
373
 
370
374
  def send_with_retries(request)
371
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
372
379
  begin
373
380
  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
381
+ await_throttle(deadline)
382
+ @direct_sender.call(request, [0, deadline - Otel.monotonic_now].max)
378
383
  rescue => e
379
- raise PayloadTooLargeError if response_status(e) == 413
384
+ record_throttle(e)
385
+ raise if oversized?(e)
380
386
  raise if attempt >= EXPORT_RETRIES || !retryable?(e)
381
387
 
382
- 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)
383
392
  retry
384
393
  end
385
394
  end
386
395
 
387
- def check_partial_success(response)
388
- partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
389
- 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?
390
400
 
391
- rejected = partial_success["rejectedSpans"]
392
- return if rejected.nil? || rejected.to_s == "0"
401
+ refs = spans.filter_map(&:ref)
402
+ return if refs.empty?
393
403
 
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
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
409
+ end
410
+
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
444
+
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
400
448
  end
401
449
 
402
- def response_status(error)
403
- return nil unless error.respond_to?(:response)
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
404
457
 
405
- response = error.response
406
- response.respond_to?(:code) ? response.code.to_i : nil
458
+ @throttle_mutex.synchronize do
459
+ @throttled_until = [@throttled_until.to_f, Otel.monotonic_now + requested / 1000.0].max
460
+ end
407
461
  end
408
462
 
409
- def retryable?(error)
410
- status = response_status(error)
411
- return true if status.nil?
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
412
478
 
413
- status >= 500 || [408, 425, 429].include?(status)
479
+ sleep(remaining)
414
480
  end
415
481
  end
416
482
 
@@ -458,10 +524,11 @@ module Bitfab
458
524
 
459
525
  def initialize(direct_sender:, max_export_batch_size: nil,
460
526
  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)
527
+ export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil, on_delivered: nil)
462
528
  raise ArgumentError, "max_request_batch_size must be a positive integer" unless max_request_batch_size.positive?
463
529
 
464
530
  @direct_sender = direct_sender
531
+ @on_delivered = on_delivered
465
532
  @max_export_batch_size = max_export_batch_size || DIRECT_MAX_EXPORT_BATCH_SIZE
466
533
  @max_request_batch_size = max_request_batch_size
467
534
  @max_queue_size = max_queue_size
@@ -475,18 +542,18 @@ module Bitfab
475
542
  Otel.register_transport(self)
476
543
  end
477
544
 
478
- def submit(operation, payload)
479
- Otel.record_submission(operation, payload)
545
+ def submit(operation, payload, meta = nil)
546
+ meta ||= CarrierMeta.new
480
547
  # Encoding is the expensive part of a submit and needs no mutual
481
548
  # exclusion, so it stays outside the lock.
482
- name = Encoder.span_name(operation, payload)
549
+ name = meta.name || "bitfab.#{operation}"
483
550
  encoded_payload = Serialize.safe_generate(
484
551
  payload,
485
552
  max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
486
553
  )
487
- started_at = Encoder.timestamp(payload, "started_at")
488
- ended_at = Encoder.timestamp(payload, "ended_at")
489
- errored = Encoder.error?(payload)
554
+ started_at = meta.started_at || Time.now
555
+ ended_at = meta.ended_at || Time.now
556
+ errored = meta.errored
490
557
 
491
558
  # Pipeline construction and the closed check are serialized: concurrent
492
559
  # first submits in a forked child would otherwise each build a pipeline,
@@ -506,6 +573,7 @@ module Bitfab
506
573
  },
507
574
  start_timestamp: started_at
508
575
  )
576
+ Otel.record_carrier_ref(span.context.hex_span_id, meta.ref) if meta.ref
509
577
  span.status = OpenTelemetry::Trace::Status.error if errored
510
578
  span.finish(end_timestamp: ended_at)
511
579
  end
@@ -630,7 +698,8 @@ module Bitfab
630
698
  direct_sender: @direct_sender,
631
699
  max_request_bytes: @max_request_bytes,
632
700
  max_request_batch_size: @max_request_batch_size,
633
- export_concurrency: @export_concurrency
701
+ export_concurrency: @export_concurrency,
702
+ on_delivered: @on_delivered
634
703
  )
635
704
  end
636
705
  end
@@ -640,7 +709,7 @@ module Bitfab
640
709
  class << self
641
710
  def encode_span(span)
642
711
  encoded = JSON.generate(span_to_otlp(span))
643
- EncodedSpan.new(encoded, encoded.bytesize)
712
+ EncodedSpan.new(encoded, encoded.bytesize, Otel.take_carrier_ref(span.hex_span_id))
644
713
  end
645
714
 
646
715
  def trim_span(span)
@@ -691,40 +760,6 @@ module Bitfab
691
760
  result
692
761
  end
693
762
 
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
763
  private
729
764
 
730
765
  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.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.5
4
+ version: 0.36.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team