bitfab 0.33.4 → 0.33.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.
@@ -0,0 +1,805 @@
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 can travel either straight to Bitfab as
17
+ # OTLP/JSON or through a customer's OTel Collector as protobuf, without the
18
+ # payload itself changing shape.
19
+ module Otel
20
+ OPERATION_ATTRIBUTE = "bitfab.operation"
21
+ PAYLOAD_ATTRIBUTE = "bitfab.payload"
22
+ OTLP_TRACES_ENDPOINT = "/api/sdk/otel/v1/traces"
23
+ COLLECTOR_ENDPOINT_ENV = "BITFAB_OTEL_EXPORTER_ENDPOINT"
24
+ MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
25
+ EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
26
+ MAX_EXPORT_REQUEST_BYTES = 3_000_000
27
+ MAX_QUEUE_SIZE = 8_192
28
+ DIRECT_MAX_EXPORT_BATCH_SIZE = 512
29
+ COLLECTOR_MAX_EXPORT_BATCH_SIZE = 32
30
+ DIRECT_MAX_REQUEST_BATCH_SIZE = 8
31
+ DEFAULT_EXPORT_CONCURRENCY = 32
32
+ MAX_EXPORT_CONCURRENCY = 64
33
+ SCHEDULE_DELAY_MILLIS = 5_000
34
+ EXPORT_TIMEOUT_MILLIS = 30_000
35
+ EXPORT_RETRIES = 3
36
+ RETRY_DELAY_SECONDS = 0.1
37
+
38
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
39
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
40
+
41
+ SPAN_KINDS = {
42
+ internal: 1,
43
+ server: 2,
44
+ client: 3,
45
+ producer: 4,
46
+ consumer: 5
47
+ }.freeze
48
+
49
+ INVALID_SPAN_ID = ("\0" * 8).b
50
+
51
+ PayloadTooLargeError = Class.new(StandardError)
52
+ PartialSuccessError = Class.new(StandardError)
53
+
54
+ class << self
55
+ def max_request_bytes_from_env
56
+ raw = ENV[MAX_REQUEST_BYTES_ENV]
57
+ return MAX_EXPORT_REQUEST_BYTES if raw.nil?
58
+
59
+ value = Integer(raw, exception: false) || 0
60
+ return value if value.positive? && value <= MAX_EXPORT_REQUEST_BYTES
61
+
62
+ Bitfab.warn_once(
63
+ "otel-max-request-bytes-invalid",
64
+ "#{MAX_REQUEST_BYTES_ENV} must be a positive integer no greater than " \
65
+ "#{MAX_EXPORT_REQUEST_BYTES}; using #{MAX_EXPORT_REQUEST_BYTES}"
66
+ )
67
+ MAX_EXPORT_REQUEST_BYTES
68
+ end
69
+
70
+ def export_concurrency_from_env
71
+ raw = ENV[EXPORT_CONCURRENCY_ENV]
72
+ return DEFAULT_EXPORT_CONCURRENCY if raw.nil?
73
+
74
+ value = Integer(raw, exception: false) || 0
75
+ return value if value.positive? && value <= MAX_EXPORT_CONCURRENCY
76
+
77
+ Bitfab.warn_once(
78
+ "otel-export-concurrency-invalid",
79
+ "#{EXPORT_CONCURRENCY_ENV} must be a positive integer no greater than " \
80
+ "#{MAX_EXPORT_CONCURRENCY}; using #{DEFAULT_EXPORT_CONCURRENCY}"
81
+ )
82
+ DEFAULT_EXPORT_CONCURRENCY
83
+ end
84
+
85
+ def create_transport(api_key:, direct_sender:)
86
+ BatchTransport.new(
87
+ api_key:,
88
+ direct_sender:,
89
+ collector_endpoint: collector_endpoint_from_env,
90
+ export_concurrency: export_concurrency_from_env,
91
+ max_request_bytes: max_request_bytes_from_env
92
+ )
93
+ end
94
+
95
+ def register_transport(transport)
96
+ ensure_process_state
97
+ live_transports_mutex.synchronize { live_transports << transport }
98
+ end
99
+
100
+ def unregister_transport(transport)
101
+ ensure_process_state
102
+ live_transports_mutex.synchronize { live_transports.delete(transport) }
103
+ end
104
+
105
+ def flush_transports(timeout = 30.0)
106
+ ensure_process_state
107
+ deadline = monotonic_now + [timeout, 0].max
108
+ current_process_transports.reduce(true) do |succeeded, transport|
109
+ transport.flush([deadline - monotonic_now, 0].max) && succeeded
110
+ end
111
+ end
112
+
113
+ def shutdown_transports(timeout = 30.0)
114
+ ensure_process_state
115
+ deadline = monotonic_now + [timeout, 0].max
116
+ current_process_transports.reduce(true) do |succeeded, transport|
117
+ transport.shutdown([deadline - monotonic_now, 0].max) && succeeded
118
+ end
119
+ end
120
+
121
+ # Count the spans submitted for each replay trace and forget them, so the
122
+ # replay barrier knows how many spans the server must have persisted
123
+ # before the run can be finalized.
124
+ def take_replay_span_counts(trace_ids)
125
+ ensure_process_state
126
+ submission_mutex.synchronize do
127
+ counts = trace_ids.each_with_object({}) do |trace_id, acc|
128
+ next unless replay_trace_submissions.include?(trace_id)
129
+
130
+ acc[trace_id] = (trace_submission_span_ids.delete(trace_id) || Set.new).size
131
+ end
132
+ replay_trace_submissions.subtract(trace_ids)
133
+ counts
134
+ end
135
+ end
136
+
137
+ def record_submission(operation, payload)
138
+ ensure_process_state
139
+ source_trace_id = payload["sourceTraceId"]
140
+ unless source_trace_id.is_a?(String)
141
+ raw_trace = payload["externalTrace"] || payload["rawTrace"]
142
+ source_trace_id = raw_trace["id"] if raw_trace.is_a?(Hash)
143
+ end
144
+ return unless source_trace_id.is_a?(String)
145
+
146
+ submission_mutex.synchronize do
147
+ if operation == "external_span"
148
+ record_span_submission(source_trace_id, payload)
149
+ elsif payload["completed"] == true
150
+ record_trace_completion(source_trace_id, payload)
151
+ end
152
+ end
153
+ end
154
+
155
+ def monotonic_now
156
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
157
+ end
158
+
159
+ # Ruby has no after-fork hook a library can register (Python's
160
+ # os.register_at_fork has no equivalent), so every entry point that
161
+ # touches module state checks the pid first. Inherited mutexes need no
162
+ # special handling: CRuby abandons a mutex held by a thread the child did
163
+ # not inherit, so the child sees it unlocked.
164
+ def ensure_process_state
165
+ @state_pid ||= Process.pid
166
+ return if @state_pid == Process.pid
167
+
168
+ reset_state_after_fork
169
+ end
170
+
171
+ # Replay submission counts belong to the parent's run, never the child's.
172
+ def reset_state_after_fork
173
+ @state_pid = Process.pid
174
+ @trace_submission_span_ids = {}
175
+ @replay_trace_submissions = Set.new
176
+ end
177
+
178
+ private
179
+
180
+ def collector_endpoint_from_env
181
+ endpoint = ENV[COLLECTOR_ENDPOINT_ENV]
182
+ (endpoint.nil? || endpoint.empty?) ? nil : endpoint
183
+ end
184
+
185
+ def record_span_submission(source_trace_id, payload)
186
+ raw_span = payload["rawSpan"]
187
+ span_id = raw_span["id"] if raw_span.is_a?(Hash)
188
+ span_id = "submission-#{monotonic_now}" unless span_id.is_a?(String)
189
+ (trace_submission_span_ids[source_trace_id] ||= Set.new) << span_id
190
+ end
191
+
192
+ def record_trace_completion(source_trace_id, payload)
193
+ if payload["testRunId"].is_a?(String)
194
+ replay_trace_submissions << source_trace_id
195
+ trace_submission_span_ids[source_trace_id] ||= Set.new
196
+ else
197
+ trace_submission_span_ids.delete(source_trace_id)
198
+ end
199
+ end
200
+
201
+ def current_process_transports
202
+ live_transports_mutex.synchronize { live_transports.select { |t| t.owner_pid == Process.pid } }
203
+ end
204
+
205
+ def live_transports
206
+ @live_transports ||= Set.new
207
+ end
208
+
209
+ def live_transports_mutex
210
+ @live_transports_mutex ||= Mutex.new
211
+ end
212
+
213
+ def submission_mutex
214
+ @submission_mutex ||= Mutex.new
215
+ end
216
+
217
+ def trace_submission_span_ids
218
+ @trace_submission_span_ids ||= {}
219
+ end
220
+
221
+ def replay_trace_submissions
222
+ @replay_trace_submissions ||= Set.new
223
+ end
224
+ end
225
+
226
+ # Encodes carrier spans as OTLP/JSON and posts them straight to Bitfab.
227
+ class DirectExporter
228
+ def initialize(direct_sender:, max_request_bytes:, max_request_batch_size:, export_concurrency:)
229
+ @direct_sender = direct_sender
230
+ @max_request_bytes = max_request_bytes
231
+ @max_request_batch_size = max_request_batch_size
232
+ @export_concurrency = export_concurrency
233
+ end
234
+
235
+ def export(spans, timeout: nil)
236
+ spans = spans.to_a
237
+ return SUCCESS if spans.empty?
238
+
239
+ begin
240
+ encoded = spans.map { |span| Encoder.span_to_otlp(span) }
241
+ rescue => e
242
+ Bitfab.warn_once(
243
+ "otel-encode-failed",
244
+ "failed to encode an OpenTelemetry span batch (#{e.message})"
245
+ )
246
+ return FAILURE
247
+ end
248
+
249
+ batches = build_request_batches(spans.first, encoded)
250
+ results = send_batches(spans.first, batches)
251
+ results.all? ? SUCCESS : FAILURE
252
+ end
253
+
254
+ def force_flush(timeout: nil)
255
+ SUCCESS
256
+ end
257
+
258
+ def shutdown(timeout: nil)
259
+ SUCCESS
260
+ end
261
+
262
+ private
263
+
264
+ def send_batches(first, batches)
265
+ results = Array.new(batches.size, false)
266
+ next_index = 0
267
+ index_mutex = Mutex.new
268
+
269
+ worker_count = [@export_concurrency, batches.size].min
270
+ workers = Array.new(worker_count) do
271
+ Thread.new do
272
+ loop do
273
+ index = index_mutex.synchronize do
274
+ current = next_index
275
+ next_index += 1 if current < batches.size
276
+ current
277
+ end
278
+ break if index >= batches.size
279
+
280
+ results[index] = send_batch(first, batches[index])
281
+ end
282
+ end
283
+ end
284
+ workers.each(&:join)
285
+ results
286
+ end
287
+
288
+ def build_request_batches(first, encoded)
289
+ batches = []
290
+ current = []
291
+
292
+ encoded.each do |span|
293
+ if current.size >= @max_request_batch_size
294
+ batches << current
295
+ current = []
296
+ end
297
+
298
+ candidate = current + [span]
299
+ if !current.empty? && Encoder.encoded_size(Encoder.build_request(first, candidate)) > @max_request_bytes
300
+ batches << current
301
+ current = [span]
302
+ else
303
+ current = candidate
304
+ end
305
+ end
306
+
307
+ batches << current unless current.empty?
308
+ batches
309
+ end
310
+
311
+ def send_batch(first, spans)
312
+ payload = Encoder.build_request(first, spans)
313
+ if Encoder.encoded_size(payload) > @max_request_bytes
314
+ warn_oversized(spans.size)
315
+ return false
316
+ end
317
+
318
+ send_with_retries(payload)
319
+ true
320
+ rescue PayloadTooLargeError
321
+ warn_oversized(spans.size)
322
+ false
323
+ rescue PartialSuccessError
324
+ false
325
+ rescue => e
326
+ Bitfab.warn_once(
327
+ "otel-export-failed",
328
+ "failed to export an OpenTelemetry span batch (further occurrences " \
329
+ "suppressed): #{e.message}"
330
+ )
331
+ false
332
+ end
333
+
334
+ def warn_oversized(span_count)
335
+ subject = (span_count == 1) ? "a single OpenTelemetry span" : "an OpenTelemetry span batch"
336
+ Bitfab.warn_once(
337
+ "otel-payload-too-large",
338
+ "#{subject} exceeded the ingestion request limit and could not be exported"
339
+ )
340
+ end
341
+
342
+ def send_with_retries(payload)
343
+ attempt = 0
344
+ begin
345
+ attempt += 1
346
+ response = @direct_sender.call(OTLP_TRACES_ENDPOINT, payload, EXPORT_TIMEOUT_MILLIS / 1000.0)
347
+ check_partial_success(response)
348
+ rescue PartialSuccessError
349
+ raise
350
+ rescue => e
351
+ raise PayloadTooLargeError if response_status(e) == 413
352
+ raise if attempt >= EXPORT_RETRIES || !retryable?(e)
353
+
354
+ sleep(RETRY_DELAY_SECONDS)
355
+ retry
356
+ end
357
+ end
358
+
359
+ def check_partial_success(response)
360
+ partial_success = response.is_a?(Hash) ? response["partialSuccess"] : nil
361
+ return unless partial_success.is_a?(Hash)
362
+
363
+ rejected = partial_success["rejectedSpans"]
364
+ return if rejected.nil? || rejected.to_s == "0"
365
+
366
+ Bitfab.warn_once(
367
+ "otel-partial-success",
368
+ "OTLP ingestion rejected #{rejected} span(s): " \
369
+ "#{partial_success["errorMessage"] || "no reason provided"}"
370
+ )
371
+ raise PartialSuccessError
372
+ end
373
+
374
+ def response_status(error)
375
+ return nil unless error.respond_to?(:response)
376
+
377
+ response = error.response
378
+ response.respond_to?(:code) ? response.code.to_i : nil
379
+ end
380
+
381
+ def retryable?(error)
382
+ status = response_status(error)
383
+ return true if status.nil?
384
+
385
+ status >= 500 || [408, 425, 429].include?(status)
386
+ end
387
+ end
388
+
389
+ # Splits an export into requests no larger than the configured byte target
390
+ # before handing them to the wrapped Collector exporter.
391
+ class SizeLimitedExporter
392
+ def initialize(exporter, max_request_bytes)
393
+ @exporter = exporter
394
+ @max_request_bytes = max_request_bytes
395
+ # The OTLP gem exposes no public way to measure an encoded request, so
396
+ # requests are partitioned by count alone when its encoder is missing.
397
+ @measurable = exporter.respond_to?(:encode, true)
398
+ end
399
+
400
+ def export(spans, timeout: nil)
401
+ batches = partition(spans.to_a)
402
+ batches.reduce(SUCCESS) do |result, batch|
403
+ exported = @exporter.export(batch, timeout:)
404
+ (exported == SUCCESS) ? result : FAILURE
405
+ end
406
+ end
407
+
408
+ def force_flush(timeout: nil)
409
+ @exporter.force_flush(timeout:)
410
+ end
411
+
412
+ def shutdown(timeout: nil)
413
+ @exporter.shutdown(timeout:)
414
+ end
415
+
416
+ private
417
+
418
+ def partition(spans)
419
+ return [spans] if !@measurable || spans.empty?
420
+
421
+ batches = []
422
+ current = []
423
+ current_size = 0
424
+
425
+ spans.each do |span|
426
+ size = span_bytes(span)
427
+ return [spans] if size.nil?
428
+
429
+ if !current.empty? && current_size + size > @max_request_bytes
430
+ batches << current
431
+ current = []
432
+ current_size = 0
433
+ end
434
+ current << span
435
+ current_size += size
436
+ end
437
+
438
+ batches << current unless current.empty?
439
+ batches
440
+ end
441
+
442
+ # The OTLP gem's encoder returns a serialized String on current versions
443
+ # and a protobuf message on older ones, and returns nil when it cannot
444
+ # encode a span at all. Anything unmeasurable falls back to count-based
445
+ # partitioning rather than failing the export from here.
446
+ def span_bytes(span)
447
+ encoded = @exporter.send(:encode, [span])
448
+ return encoded.bytesize if encoded.respond_to?(:bytesize)
449
+ return encoded.to_proto.bytesize if encoded.respond_to?(:to_proto)
450
+
451
+ nil
452
+ end
453
+ end
454
+
455
+ # Records failed exports so a flush can report delivery, not just drain.
456
+ class DeliveryTrackingExporter
457
+ def initialize(exporter)
458
+ @exporter = exporter
459
+ @mutex = Mutex.new
460
+ @failed_exports = 0
461
+ end
462
+
463
+ def export(spans, timeout: nil)
464
+ result = begin
465
+ @exporter.export(spans, timeout:)
466
+ rescue
467
+ @mutex.synchronize { @failed_exports += 1 }
468
+ raise
469
+ end
470
+ @mutex.synchronize { @failed_exports += 1 } unless result == SUCCESS
471
+ result
472
+ end
473
+
474
+ def take_failed_exports
475
+ @mutex.synchronize do
476
+ failed = @failed_exports
477
+ @failed_exports = 0
478
+ failed
479
+ end
480
+ end
481
+
482
+ def force_flush(timeout: nil)
483
+ @exporter.force_flush(timeout:)
484
+ end
485
+
486
+ def shutdown(timeout: nil)
487
+ @exporter.shutdown(timeout:)
488
+ end
489
+ end
490
+
491
+ # Owns one private TracerProvider + BatchSpanProcessor per client. The
492
+ # provider is never installed globally, so the SDK cannot disturb an
493
+ # application's own OpenTelemetry setup.
494
+ class BatchTransport
495
+ attr_reader :owner_pid
496
+
497
+ def initialize(api_key:, direct_sender:, collector_endpoint: nil, max_export_batch_size: nil,
498
+ max_request_batch_size: DIRECT_MAX_REQUEST_BATCH_SIZE, max_queue_size: MAX_QUEUE_SIZE,
499
+ export_concurrency: DEFAULT_EXPORT_CONCURRENCY, max_request_bytes: nil)
500
+ raise ArgumentError, "max_request_batch_size must be a positive integer" unless max_request_batch_size.positive?
501
+
502
+ @api_key = api_key
503
+ @direct_sender = direct_sender
504
+ @collector_endpoint = collector_endpoint
505
+ @max_export_batch_size = max_export_batch_size || default_export_batch_size
506
+ @max_request_batch_size = max_request_batch_size
507
+ @max_queue_size = max_queue_size
508
+ @export_concurrency = export_concurrency
509
+ @max_request_bytes = max_request_bytes || MAX_EXPORT_REQUEST_BYTES
510
+ @owner_pid = Process.pid
511
+ @state_mutex = Mutex.new
512
+ @flush_mutex = Mutex.new
513
+ @closed = false
514
+ create_pipeline
515
+ Otel.register_transport(self)
516
+ end
517
+
518
+ def submit(operation, payload)
519
+ Otel.record_submission(operation, payload)
520
+ # Encoding is the expensive part of a submit and needs no mutual
521
+ # exclusion, so it stays outside the lock.
522
+ name = Encoder.span_name(operation, payload)
523
+ encoded_payload = Serialize.safe_generate(payload)
524
+ started_at = Encoder.timestamp(payload, "started_at")
525
+ ended_at = Encoder.timestamp(payload, "ended_at")
526
+ errored = Encoder.error?(payload)
527
+
528
+ # Pipeline construction and the closed check are serialized: concurrent
529
+ # first submits in a forked child would otherwise each build a pipeline,
530
+ # orphaning one batch worker along with whatever it had queued.
531
+ @state_mutex.synchronize do
532
+ return warn_closed if @closed
533
+
534
+ ensure_process
535
+ tracer = @tracer
536
+ return warn_closed if tracer.nil?
537
+
538
+ span = tracer.start_root_span(
539
+ name,
540
+ attributes: {
541
+ OPERATION_ATTRIBUTE => operation,
542
+ PAYLOAD_ATTRIBUTE => encoded_payload
543
+ },
544
+ start_timestamp: started_at
545
+ )
546
+ span.status = OpenTelemetry::Trace::Status.error if errored
547
+ span.finish(end_timestamp: ended_at)
548
+ end
549
+ nil
550
+ rescue => e
551
+ Bitfab.warn_once(
552
+ "otel-submit-failed",
553
+ "failed to queue an OpenTelemetry span (further occurrences " \
554
+ "suppressed): #{e.message}"
555
+ )
556
+ nil
557
+ end
558
+
559
+ def flush(timeout = 30.0)
560
+ processor, delivery_tracker = @state_mutex.synchronize do
561
+ ensure_process
562
+ [@processor, @delivery_tracker]
563
+ end
564
+ return true if processor.nil?
565
+
566
+ flushed = false
567
+ flush_thread = Thread.new do
568
+ @flush_mutex.synchronize do
569
+ drained = begin
570
+ processor.force_flush(timeout:)
571
+ rescue => e
572
+ Bitfab.warn_once(
573
+ "otel-flush-failed",
574
+ "failed to flush OpenTelemetry spans (further occurrences " \
575
+ "suppressed): #{e.message}"
576
+ )
577
+ FAILURE
578
+ end
579
+ failed_exports = delivery_tracker.nil? ? 0 : delivery_tracker.take_failed_exports
580
+ flushed = drained == SUCCESS && failed_exports.zero?
581
+ end
582
+ end
583
+ return false unless flush_thread.join([timeout, 0].max)
584
+
585
+ flushed
586
+ end
587
+
588
+ def shutdown(timeout = 30.0)
589
+ deadline = Otel.monotonic_now + [timeout, 0].max
590
+ @state_mutex.synchronize { @closed = true }
591
+ flushed = flush([deadline - Otel.monotonic_now, 0].max)
592
+
593
+ processor = @state_mutex.synchronize do
594
+ current = @processor
595
+ @processor = nil
596
+ @provider = nil
597
+ @delivery_tracker = nil
598
+ @tracer = nil
599
+ current
600
+ end
601
+ return finish_shutdown(flushed) if processor.nil?
602
+
603
+ shutdown_thread = Thread.new { processor.shutdown(timeout: [deadline - Otel.monotonic_now, 0].max) }
604
+ joined = shutdown_thread.join([deadline - Otel.monotonic_now, 0].max)
605
+ finish_shutdown(flushed && !joined.nil?)
606
+ end
607
+
608
+ private
609
+
610
+ def finish_shutdown(succeeded)
611
+ Otel.unregister_transport(self)
612
+ succeeded
613
+ end
614
+
615
+ def warn_closed
616
+ Bitfab.warn_once(
617
+ "otel-submit-after-shutdown",
618
+ "OpenTelemetry transport is shut down; dropping spans"
619
+ )
620
+ nil
621
+ end
622
+
623
+ def default_export_batch_size
624
+ @collector_endpoint.nil? ? DIRECT_MAX_EXPORT_BATCH_SIZE : COLLECTOR_MAX_EXPORT_BATCH_SIZE
625
+ end
626
+
627
+ # A forked child inherits the parent's provider and processor but none of
628
+ # its threads, so the pipeline is rebuilt on the first submit after a fork.
629
+ # Callers hold @state_mutex, so exactly one thread rebuilds.
630
+ def ensure_process
631
+ Otel.ensure_process_state
632
+ return if @owner_pid == Process.pid && !@processor.nil?
633
+ return if @closed
634
+
635
+ @owner_pid = Process.pid
636
+ create_pipeline
637
+ Otel.register_transport(self)
638
+ end
639
+
640
+ def create_pipeline
641
+ @delivery_tracker = DeliveryTrackingExporter.new(create_exporter)
642
+ @provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
643
+ sampler: OpenTelemetry::SDK::Trace::Samplers::ALWAYS_ON,
644
+ resource: OpenTelemetry::SDK::Resources::Resource.create(
645
+ "service.name" => "bitfab-ruby-sdk",
646
+ "service.version" => Bitfab::VERSION
647
+ ),
648
+ span_limits: OpenTelemetry::SDK::Trace::SpanLimits.new(
649
+ attribute_count_limit: 2,
650
+ attribute_length_limit: nil,
651
+ event_count_limit: 128,
652
+ link_count_limit: 128,
653
+ event_attribute_count_limit: 128,
654
+ event_attribute_length_limit: nil,
655
+ link_attribute_count_limit: 128
656
+ )
657
+ )
658
+ @processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
659
+ @delivery_tracker,
660
+ exporter_timeout: EXPORT_TIMEOUT_MILLIS,
661
+ schedule_delay: SCHEDULE_DELAY_MILLIS,
662
+ max_queue_size: @max_queue_size,
663
+ max_export_batch_size: @max_export_batch_size
664
+ )
665
+ @provider.add_span_processor(@processor)
666
+ @tracer = @provider.tracer("bitfab", Bitfab::VERSION)
667
+ end
668
+
669
+ def create_exporter
670
+ return direct_exporter if @collector_endpoint.nil?
671
+
672
+ require_relative "otel_collector"
673
+ SizeLimitedExporter.new(
674
+ CollectorExporter.new(
675
+ api_key: @api_key,
676
+ endpoint: CollectorExporter.traces_endpoint(@collector_endpoint),
677
+ timeout: EXPORT_TIMEOUT_MILLIS / 1000.0
678
+ ),
679
+ @max_request_bytes
680
+ )
681
+ rescue LoadError
682
+ Bitfab.warn_once(
683
+ "otel-collector-gem-missing",
684
+ "#{COLLECTOR_ENDPOINT_ENV} is set but the opentelemetry-exporter-otlp " \
685
+ "gem is not installed; sending spans directly to Bitfab instead"
686
+ )
687
+ direct_exporter
688
+ end
689
+
690
+ def direct_exporter
691
+ DirectExporter.new(
692
+ direct_sender: @direct_sender,
693
+ max_request_bytes: @max_request_bytes,
694
+ max_request_batch_size: @max_request_batch_size,
695
+ export_concurrency: @export_concurrency
696
+ )
697
+ end
698
+ end
699
+
700
+ # Turns finished carrier spans into the OTLP/JSON shapes Bitfab ingests.
701
+ module Encoder
702
+ class << self
703
+ def build_request(first, spans)
704
+ scope = first.instrumentation_scope
705
+ {
706
+ "resourceSpans" => [{
707
+ "resource" => {"attributes" => attributes(first.resource.attribute_enumerator.to_h)},
708
+ "scopeSpans" => [{
709
+ "scope" => {"name" => scope.name, "version" => scope.version || ""},
710
+ "spans" => spans
711
+ }]
712
+ }]
713
+ }
714
+ end
715
+
716
+ def encoded_size(value)
717
+ JSON.generate(value).bytesize
718
+ end
719
+
720
+ def span_to_otlp(span)
721
+ result = {
722
+ "traceId" => span.hex_trace_id,
723
+ "spanId" => span.hex_span_id,
724
+ "name" => span.name,
725
+ "kind" => SPAN_KINDS.fetch(span.kind, 1),
726
+ "startTimeUnixNano" => (span.start_timestamp || 0).to_s,
727
+ "endTimeUnixNano" => (span.end_timestamp || 0).to_s,
728
+ "attributes" => attributes(span.attributes),
729
+ "droppedAttributesCount" => dropped_count(span.total_recorded_attributes, span.attributes),
730
+ "droppedEventsCount" => dropped_count(span.total_recorded_events, span.events),
731
+ "droppedLinksCount" => dropped_count(span.total_recorded_links, span.links),
732
+ "status" => status(span.status),
733
+ "flags" => span.trace_flags.sampled? ? 1 : 0
734
+ }
735
+ result["parentSpanId"] = span.hex_parent_span_id unless span.parent_span_id == INVALID_SPAN_ID
736
+ tracestate = span.tracestate&.to_s
737
+ result["traceState"] = tracestate unless tracestate.nil? || tracestate.empty?
738
+ result
739
+ end
740
+
741
+ def span_name(operation, payload)
742
+ raw_span = payload["rawSpan"]
743
+ if operation == "external_span" && raw_span.is_a?(Hash)
744
+ name = raw_span.dig("span_data", "name")
745
+ return name if name.is_a?(String)
746
+ end
747
+ return payload["traceFunctionKey"] if payload["traceFunctionKey"].is_a?(String)
748
+
749
+ "bitfab.#{operation}"
750
+ end
751
+
752
+ def timestamp(payload, field)
753
+ raw = payload.dig("rawSpan", field) if payload["rawSpan"].is_a?(Hash)
754
+ if raw.nil?
755
+ raw_trace = payload["externalTrace"] || payload["rawTrace"]
756
+ raw = raw_trace[field] if raw_trace.is_a?(Hash)
757
+ end
758
+ return Time.now unless raw.is_a?(String)
759
+
760
+ begin
761
+ Time.iso8601(raw)
762
+ rescue ArgumentError
763
+ Time.now
764
+ end
765
+ end
766
+
767
+ def error?(payload)
768
+ raw_span = payload["rawSpan"]
769
+ return true if raw_span.is_a?(Hash) && !raw_span.dig("span_data", "error").nil?
770
+
771
+ errors = payload["errors"]
772
+ errors.is_a?(Array) && !errors.empty?
773
+ end
774
+
775
+ private
776
+
777
+ def status(span_status)
778
+ result = {"code" => span_status.code}
779
+ description = span_status.description
780
+ result["message"] = description unless description.nil? || description.empty?
781
+ result
782
+ end
783
+
784
+ def dropped_count(total_recorded, recorded)
785
+ [total_recorded - (recorded&.size || 0), 0].max
786
+ end
787
+
788
+ def attributes(values)
789
+ (values || {}).map { |key, value| {"key" => key.to_s, "value" => attribute_value(value)} }
790
+ end
791
+
792
+ def attribute_value(value)
793
+ case value
794
+ when true, false then {"boolValue" => value}
795
+ when Integer then {"intValue" => value.to_s}
796
+ when Float then {"doubleValue" => value}
797
+ when String then {"stringValue" => value}
798
+ when Array then {"arrayValue" => {"values" => value.map { |item| attribute_value(item) }}}
799
+ else {"stringValue" => value.to_s}
800
+ end
801
+ end
802
+ end
803
+ end
804
+ end
805
+ end