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