bitfab 0.33.5 → 0.34.0

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.
@@ -0,0 +1,717 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "time"
6
+ require "opentelemetry/sdk"
7
+
8
+ require_relative "serialize"
9
+ require_relative "version"
10
+ require_relative "warn_once"
11
+
12
+ module Bitfab
13
+ # OpenTelemetry delivery for Bitfab spans and trace completions.
14
+ #
15
+ # Bitfab payloads ride as OTLP span attributes (bitfab.operation +
16
+ # bitfab.payload), so a batch travels to Bitfab as OTLP/JSON without the
17
+ # payload itself changing shape.
18
+ module Otel
19
+ OPERATION_ATTRIBUTE = "bitfab.operation"
20
+ PAYLOAD_ATTRIBUTE = "bitfab.payload"
21
+ OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces"
22
+ MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
23
+ EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
24
+ MAX_EXPORT_REQUEST_BYTES = 3_000_000
25
+ MAX_QUEUE_SIZE = 8_192
26
+ DIRECT_MAX_EXPORT_BATCH_SIZE = 512
27
+ DIRECT_MAX_REQUEST_BATCH_SIZE = 8
28
+ DEFAULT_EXPORT_CONCURRENCY = 32
29
+ MAX_EXPORT_CONCURRENCY = 64
30
+ SCHEDULE_DELAY_MILLIS = 5_000
31
+ EXPORT_TIMEOUT_MILLIS = 30_000
32
+ EXPORT_RETRIES = 3
33
+ RETRY_DELAY_SECONDS = 0.1
34
+
35
+ # The comma that joins adjacent spans in the request's span list.
36
+ SPAN_SEPARATOR_BYTES = 1
37
+
38
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
39
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
40
+
41
+ # A span encoded exactly as it goes on the wire, carrying its byte count.
42
+ # Encoding once and remembering the size is what keeps request packing
43
+ # linear: sizing a candidate batch by re-encoding the whole request
44
+ # re-escapes every carrier's bitfab.payload string on every span considered.
45
+ EncodedSpan = Struct.new(:encoded, :size)
46
+
47
+ # The invariant head and tail of an OTLP request for one export window. Key
48
+ # order matches what JSON.generate emits for the equivalent Hash, so a body
49
+ # assembled by concatenation is byte-identical to encoding that Hash.
50
+ RequestEnvelope = Struct.new(:head, :tail, :size)
51
+
52
+ RequestBatch = Struct.new(:spans, :size)
53
+
54
+ SPAN_KINDS = {
55
+ internal: 1,
56
+ server: 2,
57
+ client: 3,
58
+ producer: 4,
59
+ consumer: 5
60
+ }.freeze
61
+
62
+ INVALID_SPAN_ID = ("\0" * 8).b
63
+
64
+ PayloadTooLargeError = Class.new(StandardError)
65
+ PartialSuccessError = Class.new(StandardError)
66
+
67
+ class << self
68
+ def max_request_bytes_from_env
69
+ raw = ENV[MAX_REQUEST_BYTES_ENV]
70
+ return MAX_EXPORT_REQUEST_BYTES if raw.nil?
71
+
72
+ value = Integer(raw, exception: false) || 0
73
+ return value if value.positive? && value <= MAX_EXPORT_REQUEST_BYTES
74
+
75
+ Bitfab.warn_once(
76
+ "otel-max-request-bytes-invalid",
77
+ "#{MAX_REQUEST_BYTES_ENV} must be a positive integer no greater than " \
78
+ "#{MAX_EXPORT_REQUEST_BYTES}; using #{MAX_EXPORT_REQUEST_BYTES}"
79
+ )
80
+ MAX_EXPORT_REQUEST_BYTES
81
+ end
82
+
83
+ def export_concurrency_from_env
84
+ raw = ENV[EXPORT_CONCURRENCY_ENV]
85
+ return DEFAULT_EXPORT_CONCURRENCY if raw.nil?
86
+
87
+ value = Integer(raw, exception: false) || 0
88
+ return value if value.positive? && value <= MAX_EXPORT_CONCURRENCY
89
+
90
+ Bitfab.warn_once(
91
+ "otel-export-concurrency-invalid",
92
+ "#{EXPORT_CONCURRENCY_ENV} must be a positive integer no greater than " \
93
+ "#{MAX_EXPORT_CONCURRENCY}; using #{DEFAULT_EXPORT_CONCURRENCY}"
94
+ )
95
+ DEFAULT_EXPORT_CONCURRENCY
96
+ end
97
+
98
+ def create_transport(direct_sender:)
99
+ BatchTransport.new(
100
+ direct_sender:,
101
+ export_concurrency: export_concurrency_from_env,
102
+ max_request_bytes: max_request_bytes_from_env
103
+ )
104
+ end
105
+
106
+ def register_transport(transport)
107
+ ensure_process_state
108
+ live_transports_mutex.synchronize { live_transports << transport }
109
+ end
110
+
111
+ def unregister_transport(transport)
112
+ ensure_process_state
113
+ live_transports_mutex.synchronize { live_transports.delete(transport) }
114
+ end
115
+
116
+ def flush_transports(timeout = 30.0)
117
+ ensure_process_state
118
+ deadline = monotonic_now + [timeout, 0].max
119
+ current_process_transports.reduce(true) do |succeeded, transport|
120
+ transport.flush([deadline - monotonic_now, 0].max) && succeeded
121
+ end
122
+ end
123
+
124
+ def shutdown_transports(timeout = 30.0)
125
+ ensure_process_state
126
+ deadline = monotonic_now + [timeout, 0].max
127
+ current_process_transports.reduce(true) do |succeeded, transport|
128
+ transport.shutdown([deadline - monotonic_now, 0].max) && succeeded
129
+ end
130
+ end
131
+
132
+ # Count the spans submitted for each replay trace and forget them, so the
133
+ # replay barrier knows how many spans the server must have persisted
134
+ # before the run can be finalized.
135
+ def take_replay_span_counts(trace_ids)
136
+ ensure_process_state
137
+ submission_mutex.synchronize do
138
+ counts = trace_ids.each_with_object({}) do |trace_id, acc|
139
+ next unless replay_trace_submissions.include?(trace_id)
140
+
141
+ acc[trace_id] = (trace_submission_span_ids.delete(trace_id) || Set.new).size
142
+ end
143
+ replay_trace_submissions.subtract(trace_ids)
144
+ counts
145
+ end
146
+ end
147
+
148
+ def record_submission(operation, payload)
149
+ ensure_process_state
150
+ source_trace_id = payload["sourceTraceId"]
151
+ unless source_trace_id.is_a?(String)
152
+ raw_trace = payload["externalTrace"] || payload["rawTrace"]
153
+ source_trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
154
+ end
155
+ return unless source_trace_id.is_a?(String)
156
+
157
+ submission_mutex.synchronize do
158
+ if operation == "external_span"
159
+ record_span_submission(source_trace_id, payload)
160
+ elsif payload["completed"] == true
161
+ record_trace_completion(source_trace_id, payload)
162
+ end
163
+ end
164
+ end
165
+
166
+ def monotonic_now
167
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
168
+ end
169
+
170
+ # Ruby has no after-fork hook a library can register (Python's
171
+ # os.register_at_fork has no equivalent), so every entry point that
172
+ # touches module state checks the pid first. Inherited mutexes need no
173
+ # special handling: CRuby abandons a mutex held by a thread the child did
174
+ # not inherit, so the child sees it unlocked.
175
+ def ensure_process_state
176
+ @state_pid ||= Process.pid
177
+ return if @state_pid == Process.pid
178
+
179
+ reset_state_after_fork
180
+ end
181
+
182
+ # Replay submission counts belong to the parent's run, never the child's.
183
+ def reset_state_after_fork
184
+ @state_pid = Process.pid
185
+ @trace_submission_span_ids = {}
186
+ @replay_trace_submissions = Set.new
187
+ end
188
+
189
+ private
190
+
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
+ def current_process_transports
208
+ live_transports_mutex.synchronize { live_transports.select { |t| t.owner_pid == Process.pid } }
209
+ end
210
+
211
+ def live_transports
212
+ @live_transports ||= Set.new
213
+ end
214
+
215
+ def live_transports_mutex
216
+ @live_transports_mutex ||= Mutex.new
217
+ end
218
+
219
+ def submission_mutex
220
+ @submission_mutex ||= Mutex.new
221
+ end
222
+
223
+ def trace_submission_span_ids
224
+ @trace_submission_span_ids ||= {}
225
+ end
226
+
227
+ def replay_trace_submissions
228
+ @replay_trace_submissions ||= Set.new
229
+ end
230
+ end
231
+
232
+ # Encodes carrier spans as OTLP/JSON and posts them straight to Bitfab.
233
+ class DirectExporter
234
+ def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:)
235
+ @direct_sender = direct_sender
236
+ @max_request_bytes = max_request_bytes
237
+ @max_request_batch_size = max_request_batch_size
238
+ @export_concurrency = export_concurrency
239
+ end
240
+
241
+ def export(spans, timeout: nil)
242
+ spans = spans.to_a
243
+ return SUCCESS if spans.empty?
244
+
245
+ begin
246
+ encoded = spans.map { |span| Encoder.encode_span(span) }
247
+ envelope = Encoder.request_envelope(spans.first)
248
+ rescue => e
249
+ Bitfab.warn_once(
250
+ "otel-encode-failed",
251
+ "failed to encode an OpenTelemetry span batch (#{e.message})"
252
+ )
253
+ return FAILURE
254
+ end
255
+
256
+ batches = build_request_batches(envelope, encoded)
257
+ results = send_batches(envelope, batches)
258
+ results.all? ? SUCCESS : FAILURE
259
+ end
260
+
261
+ def force_flush(timeout: nil)
262
+ SUCCESS
263
+ end
264
+
265
+ def shutdown(timeout: nil)
266
+ SUCCESS
267
+ end
268
+
269
+ private
270
+
271
+ def send_batches(envelope, batches)
272
+ results = Array.new(batches.size, false)
273
+ next_index = 0
274
+ index_mutex = Mutex.new
275
+
276
+ worker_count = [@export_concurrency, batches.size].min
277
+ workers = Array.new(worker_count) do
278
+ Thread.new do
279
+ loop do
280
+ index = index_mutex.synchronize do
281
+ current = next_index
282
+ next_index += 1 if current < batches.size
283
+ current
284
+ end
285
+ break if index >= batches.size
286
+
287
+ results[index] = send_batch(envelope, batches[index])
288
+ end
289
+ end
290
+ end
291
+ workers.each(&:join)
292
+ results
293
+ end
294
+
295
+ def build_request_batches(envelope, encoded)
296
+ batches = []
297
+ current = []
298
+ size = envelope.size
299
+
300
+ encoded.each do |span|
301
+ addition = span.size + (current.empty? ? 0 : SPAN_SEPARATOR_BYTES)
302
+ if !current.empty? && (current.size >= @max_request_batch_size || size + addition > @max_request_bytes)
303
+ batches << RequestBatch.new(current, size)
304
+ current = []
305
+ size = envelope.size
306
+ addition = span.size
307
+ end
308
+ current << span
309
+ size += addition
310
+ end
311
+
312
+ batches << RequestBatch.new(current, size) unless current.empty?
313
+ batches
314
+ end
315
+
316
+ def send_batch(envelope, batch)
317
+ if batch.size > @max_request_bytes
318
+ warn_oversized(batch.spans.size)
319
+ return false
320
+ 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
+ false
329
+ rescue => e
330
+ Bitfab.warn_once(
331
+ "otel-export-failed",
332
+ "failed to export an OpenTelemetry span batch (further occurrences " \
333
+ "suppressed): #{e.message}"
334
+ )
335
+ false
336
+ end
337
+
338
+ def warn_oversized(span_count)
339
+ subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
340
+ Bitfab.warn_once(
341
+ "otel-payload-too-large",
342
+ "#{subject} exceeded the ingestion request limit and could not be exported"
343
+ )
344
+ end
345
+
346
+ def send_with_retries(body)
347
+ attempt = 0
348
+ begin
349
+ attempt += 1
350
+ response = @direct_sender.call(OTLP_TRACES_ENDPOINT, body, EXPORT_TIMEOUT_MILLIS / 1000.0)
351
+ check_partial_success(response)
352
+ rescue PartialSuccessError
353
+ raise
354
+ rescue => e
355
+ raise PayloadTooLargeError if response_status(e) == 413
356
+ raise if attempt >= EXPORT_RETRIES || !retryable?(e)
357
+
358
+ sleep(RETRY_DELAY_SECONDS)
359
+ retry
360
+ end
361
+ end
362
+
363
+ def check_partial_success(response)
364
+ partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
365
+ return unless partial_success.is_a?(Hash)
366
+
367
+ rejected = partial_success["rejectedSpans"]
368
+ return if rejected.nil? || rejected.to_s == "0"
369
+
370
+ Bitfab.warn_once(
371
+ "otel-partial-success",
372
+ "OTLP ingestion rejected #{rejected} span(s): " \
373
+ "#{partial_success["errorMessage"] || "no reason provided"}"
374
+ )
375
+ raise PartialSuccessError
376
+ end
377
+
378
+ def response_status(error)
379
+ return nil unless error.respond_to?(:response)
380
+
381
+ response = error.response
382
+ response.respond_to?(:code) ? response.code.to_i : nil
383
+ end
384
+
385
+ def retryable?(error)
386
+ status = response_status(error)
387
+ return true if status.nil?
388
+
389
+ status >= 500 || [408, 425, 429].include?(status)
390
+ end
391
+ end
392
+
393
+ # Records failed exports so a flush can report delivery, not just drain.
394
+ class DeliveryTrackingExporter
395
+ def initialize(exporter)
396
+ @exporter = exporter
397
+ @mutex = Mutex.new
398
+ @failed_exports = 0
399
+ end
400
+
401
+ def export(spans, timeout: nil)
402
+ result = begin
403
+ @exporter.export(spans, timeout:)
404
+ rescue
405
+ @mutex.synchronize { @failed_exports += 1 }
406
+ raise
407
+ end
408
+ @mutex.synchronize { @failed_exports += 1 } unless result == SUCCESS
409
+ result
410
+ end
411
+
412
+ def take_failed_exports
413
+ @mutex.synchronize do
414
+ failed = @failed_exports
415
+ @failed_exports = 0
416
+ failed
417
+ end
418
+ end
419
+
420
+ def force_flush(timeout: nil)
421
+ @exporter.force_flush(timeout:)
422
+ end
423
+
424
+ def shutdown(timeout: nil)
425
+ @exporter.shutdown(timeout:)
426
+ end
427
+ end
428
+
429
+ # Owns one private TracerProvider + BatchSpanProcessor per client. The
430
+ # provider is never installed globally, so the SDK cannot disturb an
431
+ # application's own OpenTelemetry setup.
432
+ class BatchTransport
433
+ attr_reader :owner_pid
434
+
435
+ def initialize(direct_sender:, max_export_batch_size: nil,
436
+ 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)
438
+ raise ArgumentError, "max_request_batch_size must be a positive integer" unless max_request_batch_size.positive?
439
+
440
+ @direct_sender = direct_sender
441
+ @max_export_batch_size = max_export_batch_size || DIRECT_MAX_EXPORT_BATCH_SIZE
442
+ @max_request_batch_size = max_request_batch_size
443
+ @max_queue_size = max_queue_size
444
+ @export_concurrency = export_concurrency
445
+ @max_request_bytes = max_request_bytes || MAX_EXPORT_REQUEST_BYTES
446
+ @owner_pid = Process.pid
447
+ @state_mutex = Mutex.new
448
+ @flush_mutex = Mutex.new
449
+ @closed = false
450
+ create_pipeline
451
+ Otel.register_transport(self)
452
+ end
453
+
454
+ def submit(operation, payload)
455
+ Otel.record_submission(operation, payload)
456
+ # Encoding is the expensive part of a submit and needs no mutual
457
+ # exclusion, so it stays outside the lock.
458
+ name = Encoder.span_name(operation, payload)
459
+ encoded_payload = Serialize.safe_generate(payload)
460
+ started_at = Encoder.timestamp(payload, "started_at")
461
+ ended_at = Encoder.timestamp(payload, "ended_at")
462
+ errored = Encoder.error?(payload)
463
+
464
+ # Pipeline construction and the closed check are serialized: concurrent
465
+ # first submits in a forked child would otherwise each build a pipeline,
466
+ # orphaning one batch worker along with whatever it had queued.
467
+ @state_mutex.synchronize do
468
+ return warn_closed if @closed
469
+
470
+ ensure_process
471
+ tracer = @tracer
472
+ return warn_closed if tracer.nil?
473
+
474
+ span = tracer.start_root_span(
475
+ name,
476
+ attributes: {
477
+ OPERATION_ATTRIBUTE => operation,
478
+ PAYLOAD_ATTRIBUTE => encoded_payload
479
+ },
480
+ start_timestamp: started_at
481
+ )
482
+ span.status = OpenTelemetry::Trace::Status.error if errored
483
+ span.finish(end_timestamp: ended_at)
484
+ end
485
+ nil
486
+ rescue => e
487
+ Bitfab.warn_once(
488
+ "otel-submit-failed",
489
+ "failed to queue an OpenTelemetry span (further occurrences " \
490
+ "suppressed): #{e.message}"
491
+ )
492
+ nil
493
+ end
494
+
495
+ def flush(timeout = 30.0)
496
+ processor, delivery_tracker = @state_mutex.synchronize do
497
+ ensure_process
498
+ [@processor, @delivery_tracker]
499
+ end
500
+ return true if processor.nil?
501
+
502
+ flushed = false
503
+ flush_thread = Thread.new do
504
+ @flush_mutex.synchronize do
505
+ drained = begin
506
+ processor.force_flush(timeout:)
507
+ rescue => e
508
+ Bitfab.warn_once(
509
+ "otel-flush-failed",
510
+ "failed to flush OpenTelemetry spans (further occurrences " \
511
+ "suppressed): #{e.message}"
512
+ )
513
+ FAILURE
514
+ end
515
+ failed_exports = delivery_tracker.nil? ? 0 : delivery_tracker.take_failed_exports
516
+ flushed = drained == SUCCESS && failed_exports.zero?
517
+ end
518
+ end
519
+ return false unless flush_thread.join([timeout, 0].max)
520
+
521
+ flushed
522
+ end
523
+
524
+ def shutdown(timeout = 30.0)
525
+ deadline = Otel.monotonic_now + [timeout, 0].max
526
+ @state_mutex.synchronize { @closed = true }
527
+ flushed = flush([deadline - Otel.monotonic_now, 0].max)
528
+
529
+ processor = @state_mutex.synchronize do
530
+ current = @processor
531
+ @processor = nil
532
+ @provider = nil
533
+ @delivery_tracker = nil
534
+ @tracer = nil
535
+ current
536
+ end
537
+ return finish_shutdown(flushed) if processor.nil?
538
+
539
+ shutdown_thread = Thread.new { processor.shutdown(timeout: [deadline - Otel.monotonic_now, 0].max) }
540
+ joined = shutdown_thread.join([deadline - Otel.monotonic_now, 0].max)
541
+ finish_shutdown(flushed && !joined.nil?)
542
+ end
543
+
544
+ private
545
+
546
+ def finish_shutdown(succeeded)
547
+ Otel.unregister_transport(self)
548
+ succeeded
549
+ end
550
+
551
+ def warn_closed
552
+ Bitfab.warn_once(
553
+ "otel-submit-after-shutdown",
554
+ "OpenTelemetry transport is shut down; dropping spans"
555
+ )
556
+ nil
557
+ end
558
+
559
+ # A forked child inherits the parent's provider and processor but none of
560
+ # its threads, so the pipeline is rebuilt on the first submit after a fork.
561
+ # Callers hold @state_mutex, so exactly one thread rebuilds.
562
+ def ensure_process
563
+ Otel.ensure_process_state
564
+ return if @owner_pid == Process.pid && !@processor.nil?
565
+ return if @closed
566
+
567
+ @owner_pid = Process.pid
568
+ create_pipeline
569
+ Otel.register_transport(self)
570
+ end
571
+
572
+ def create_pipeline
573
+ @delivery_tracker = DeliveryTrackingExporter.new(create_exporter)
574
+ @provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
575
+ sampler: OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON,
576
+ resource: OpenTelemetry::SDK::Resources::Resource.create(
577
+ "service.name" => "bitfab-ruby-sdk",
578
+ "service.version" => Bitfab::VERSION
579
+ ),
580
+ span_limits: OpenTelemetry::SDK::Trace::SpanLimits.new(
581
+ attribute_count_limit: 2,
582
+ attribute_length_limit: nil,
583
+ event_count_limit: 128,
584
+ link_count_limit: 128,
585
+ event_attribute_count_limit: 128,
586
+ event_attribute_length_limit: nil,
587
+ link_attribute_count_limit: 128
588
+ )
589
+ )
590
+ @processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
591
+ @delivery_tracker,
592
+ exporter_timeout: EXPORT_TIMEOUT_MILLIS,
593
+ schedule_delay: SCHEDULE_DELAY_MILLIS,
594
+ max_queue_size: @max_queue_size,
595
+ max_export_batch_size: @max_export_batch_size
596
+ )
597
+ @provider.add_span_processor(@processor)
598
+ @tracer = @provider.tracer("bitfab", Bitfab::VERSION)
599
+ end
600
+
601
+ def create_exporter
602
+ DirectExporter.new(
603
+ direct_sender: @direct_sender,
604
+ max_request_bytes: @max_request_bytes,
605
+ max_request_batch_size: @max_request_batch_size,
606
+ export_concurrency: @export_concurrency
607
+ )
608
+ end
609
+ end
610
+
611
+ # Turns finished carrier spans into the OTLP/JSON shapes Bitfab ingests.
612
+ module Encoder
613
+ class << self
614
+ def encode_span(span)
615
+ encoded = JSON.generate(span_to_otlp(span))
616
+ EncodedSpan.new(encoded, encoded.bytesize)
617
+ end
618
+
619
+ def request_envelope(first)
620
+ scope = first.instrumentation_scope
621
+ resource = JSON.generate({"attributes" => attributes(first.resource.attribute_enumerator.to_h)})
622
+ scope_json = JSON.generate({"name" => scope.name, "version" => scope.version || ""})
623
+ head = %({"resourceSpans":[{"resource":#{resource},"scopeSpans":[{"scope":#{scope_json},"spans":[)
624
+ tail = "]}]}]}"
625
+ RequestEnvelope.new(head, tail, (head + tail).bytesize)
626
+ end
627
+
628
+ def encode_request(envelope, spans)
629
+ envelope.head + spans.map(&:encoded).join(",") + envelope.tail
630
+ end
631
+
632
+ def span_to_otlp(span)
633
+ result = {
634
+ "traceId" => span.hex_trace_id,
635
+ "spanId" => span.hex_span_id,
636
+ "name" => span.name,
637
+ "kind" => SPAN_KINDS.fetch(span.kind, 1),
638
+ "startTimeUnixNano" => (span.start_timestamp || 0).to_s,
639
+ "endTimeUnixNano" => (span.end_timestamp || 0).to_s,
640
+ "attributes" => attributes(span.attributes),
641
+ "droppedAttributesCount" => dropped_count(span.total_recorded_attributes, span.attributes),
642
+ "droppedEventsCount" => dropped_count(span.total_recorded_events, span.events),
643
+ "droppedLinksCount" => dropped_count(span.total_recorded_links, span.links),
644
+ "status" => status(span.status),
645
+ "flags" => span.trace_flags.sampled? ? 1 : 0
646
+ }
647
+ result["parentSpanId"] = span.hex_parent_span_id unless span.parent_span_id == INVALID_SPAN_ID
648
+ tracestate = span.tracestate&.to_s
649
+ result["traceState"] = tracestate unless tracestate.nil? || tracestate.empty?
650
+ result
651
+ end
652
+
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
+ private
688
+
689
+ def status(span_status)
690
+ result = {"code" => span_status.code}
691
+ description = span_status.description
692
+ result["message"] = description unless description.nil? || description.empty?
693
+ result
694
+ end
695
+
696
+ def dropped_count(total_recorded, recorded)
697
+ [total_recorded - (recorded&.size || 0), 0].max
698
+ end
699
+
700
+ def attributes(values)
701
+ (values || {}).map { |key, value| {"key" => key.to_s, "value" => attribute_value(value)} }
702
+ end
703
+
704
+ def attribute_value(value)
705
+ case value
706
+ when true, false then {"boolValue" => value}
707
+ when Integer then {"intValue" => value.to_s}
708
+ when Float then {"doubleValue" => value}
709
+ when String then {"stringValue" => value}
710
+ when Array then {"arrayValue" => {"values" => value.map { |item| attribute_value(item) }}}
711
+ else {"stringValue" => value.to_s}
712
+ end
713
+ end
714
+ end
715
+ end
716
+ end
717
+ end