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 +4 -4
- data/lib/bitfab/http_client.rb +272 -5
- data/lib/bitfab/otel.rb +175 -140
- data/lib/bitfab/replay.rb +37 -12
- data/lib/bitfab/transport.rb +2 -6
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab/warn_once.rb +10 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd73e07889e4f74cd94c6ca534e423a7aa95e3ce6f1e1ba306e9fe33df8de8df
|
|
4
|
+
data.tar.gz: fb2eb3cd99066d8338f6ad03d18bc3a8de9f3955659aa66835c83066a95d3595
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b7155a3d0426c00f57668975485f2ba722bd3f0555dd897024f586290b36265f5851d49cc88242eb552421758b9d624b31e21edfb8290e68f693110e8b93cd32
|
|
7
|
+
data.tar.gz: 87ae26a27f8864294f431b80d87f0a7f768fdb2a2f09786929dad90d61316ce7c1106e75fe9271c63260cebe3545a2e73676e94295aa281f4d24c0d101184b0f
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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(:
|
|
382
|
+
direct_sender: method(:deliver_carriers),
|
|
383
|
+
on_delivered: method(:record_delivered_carriers)
|
|
287
384
|
)
|
|
288
385
|
end
|
|
289
386
|
end
|
|
290
387
|
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
#
|
|
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
|
-
@
|
|
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
|
|
226
|
-
@
|
|
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
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
384
|
+
record_throttle(e)
|
|
385
|
+
raise if oversized?(e)
|
|
380
386
|
raise if attempt >= EXPORT_RETRIES || !retryable?(e)
|
|
381
387
|
|
|
382
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
392
|
-
return if
|
|
401
|
+
refs = spans.filter_map(&:ref)
|
|
402
|
+
return if refs.empty?
|
|
393
403
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
"
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
488
|
-
ended_at =
|
|
489
|
-
errored =
|
|
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
|
-
#
|
|
1013
|
-
#
|
|
1014
|
-
#
|
|
1015
|
-
#
|
|
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
|
-
|
|
1018
|
-
return if expected_span_counts.empty?
|
|
1020
|
+
trace_ids = sdk_trace_ids.compact
|
|
1019
1021
|
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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(
|
data/lib/bitfab/transport.rb
CHANGED
|
@@ -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
|
data/lib/bitfab/version.rb
CHANGED
data/lib/bitfab/warn_once.rb
CHANGED
|
@@ -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 }
|