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.
- checksums.yaml +4 -4
- data/README.md +43 -4
- data/lib/bitfab/client.rb +26 -51
- data/lib/bitfab/http_client.rb +67 -74
- data/lib/bitfab/otel.rb +717 -0
- data/lib/bitfab/replay.rb +48 -24
- data/lib/bitfab/serialize.rb +40 -0
- data/lib/bitfab/transport.rb +27 -0
- data/lib/bitfab/version.rb +1 -1
- metadata +23 -1
data/lib/bitfab/otel.rb
ADDED
|
@@ -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
|