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.
- checksums.yaml +4 -4
- data/README.md +68 -4
- data/lib/bitfab/client.rb +26 -51
- data/lib/bitfab/http_client.rb +62 -74
- data/lib/bitfab/otel.rb +805 -0
- data/lib/bitfab/otel_collector.rb +28 -0
- data/lib/bitfab/replay.rb +65 -31
- data/lib/bitfab/serialize.rb +40 -0
- data/lib/bitfab/transport.rb +27 -0
- data/lib/bitfab/version.rb +1 -1
- metadata +38 -1
data/lib/bitfab/otel.rb
ADDED
|
@@ -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
|