bitfab 0.36.4 → 0.36.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5578e8fdcdd81610b4e65fa94a5e9be88339e816742f6ba7fea81b53867eedf5
4
- data.tar.gz: 308d35969ac643d1ddcfb76c169a6108906c958275b392f7d159ea647b0bf63c
3
+ metadata.gz: 49b714ee6d468fc34c0c6009802c5736d33f03275f06d7d7148635b5d70b7caa
4
+ data.tar.gz: 3816843ddea8722c0222cfc0bed5c65ea05f5abb5ff764233e5ab302d609f2d2
5
5
  SHA512:
6
- metadata.gz: 2c0511909223595a7792e780d0c77b8c0f5ef2d239e4b2a5dda7946a63fbe4801893ae02ba3392b74520ad9d82844e519d3319a900f22c30bef4857e616c0420
7
- data.tar.gz: 0ae83e2f731bc2f6fa9f564b6fe8754b7fff82c5ae03a914067cca6fb75f4ddb10e935bba8976de6f0dabe7025812e49d9392e2ace61b5202c02d80b21fe00aa
6
+ metadata.gz: 1f5504e7d7622ccf55145b9124ee0d8ca42f774bdaea720a92382af42148bbf16afb72708d673fb359b37a9840364a770e4a1a87003f016aff2ebd1a59a52b38
7
+ data.tar.gz: db5fbc39adf358e640ae315348eb15ceb5798c67747d092eaaf37651cf9a89542ae9c4d8899a44ebfd4425ba623598e9e5af382dec11f119a917d52cf9ccb0dd
@@ -12,18 +12,29 @@ module Bitfab
12
12
  # batches) ride uncompressed.
13
13
  MIN_COMPRESSED_BYTES = 8_192
14
14
 
15
+ PreparedRequest = Struct.new(:body, :content_encoding, :raw_bytes, :wire_bytes)
16
+
15
17
  module_function
16
18
 
17
19
  # Returns the body to send and the Content-Encoding it carries, or nil when
18
20
  # the body is sent as-is. Compression is best-effort: any failure sends the
19
21
  # original body rather than dropping the span.
20
22
  def encode_request_body(body)
21
- return [body, nil] if ENV[DISABLE_COMPRESSION_ENV]
22
- return [body, nil] if body.bytesize < MIN_COMPRESSED_BYTES
23
+ prepared = prepare_request_body(body)
24
+ [prepared.body, prepared.content_encoding]
25
+ end
26
+
27
+ def prepare_request_body(body)
28
+ size = body.bytesize
29
+ return PreparedRequest.new(body, nil, size, size) if ENV[DISABLE_COMPRESSION_ENV]
30
+ return PreparedRequest.new(body, nil, size, size) if size < MIN_COMPRESSED_BYTES
31
+
32
+ compressed = Zlib.gzip(body)
33
+ return PreparedRequest.new(body, nil, size, size) if compressed.bytesize >= size
23
34
 
24
- [Zlib.gzip(body), "gzip"]
35
+ PreparedRequest.new(compressed, "gzip", size, compressed.bytesize)
25
36
  rescue
26
- [body, nil]
37
+ PreparedRequest.new(body, nil, body.bytesize, body.bytesize)
27
38
  end
28
39
  end
29
40
  end
@@ -58,6 +58,16 @@ module Bitfab
58
58
  # POST an already-encoded body. The span transport encodes its own batches,
59
59
  # so routing them back through #request would encode the same data twice.
60
60
  def send_encoded(endpoint, body, timeout: nil, max_retries: 1, retry_delay: 0.1)
61
+ send_prepared(
62
+ endpoint,
63
+ Compress.prepare_request_body(body),
64
+ timeout:,
65
+ max_retries:,
66
+ retry_delay:
67
+ )
68
+ end
69
+
70
+ def send_prepared(endpoint, prepared, timeout: nil, max_retries: 1, retry_delay: 0.1)
61
71
  uri = URI("#{@service_url}#{endpoint}")
62
72
  request_timeout = timeout || @timeout
63
73
 
@@ -70,9 +80,8 @@ module Bitfab
70
80
  http.read_timeout = request_timeout
71
81
 
72
82
  req = Net::HTTP::Post.new(uri.path, headers)
73
- encoded_body, content_encoding = Compress.encode_request_body(body)
74
- req["Content-Encoding"] = content_encoding if content_encoding
75
- req.body = encoded_body
83
+ req["Content-Encoding"] = prepared.content_encoding if prepared.content_encoding
84
+ req.body = prepared.body
76
85
 
77
86
  response = http.request(req)
78
87
 
@@ -279,8 +288,8 @@ module Bitfab
279
288
  end
280
289
  end
281
290
 
282
- def send_transport_request(endpoint, body, timeout)
283
- send_encoded(endpoint, body, timeout:, max_retries: 1)
291
+ def send_transport_request(endpoint, request, timeout)
292
+ send_prepared(endpoint, request, timeout:, max_retries: 1)
284
293
  end
285
294
 
286
295
  # Normalize each entry to a hash with stable string keys, accepting either
data/lib/bitfab/otel.rb CHANGED
@@ -5,6 +5,7 @@ require "net/http"
5
5
  require "time"
6
6
  require "opentelemetry/sdk"
7
7
 
8
+ require_relative "compress"
8
9
  require_relative "serialize"
9
10
  require_relative "version"
10
11
  require_relative "warn_once"
@@ -22,6 +23,7 @@ module Bitfab
22
23
  MAX_REQUEST_BYTES_ENV = "BITFAB_OTEL_MAX_REQUEST_BYTES"
23
24
  EXPORT_CONCURRENCY_ENV = "BITFAB_OTEL_EXPORT_CONCURRENCY"
24
25
  MAX_EXPORT_REQUEST_BYTES = 3_000_000
26
+ MAX_DECOMPRESSED_REQUEST_BYTES = 8_000_000
25
27
  MAX_QUEUE_SIZE = 8_192
26
28
  DIRECT_MAX_EXPORT_BATCH_SIZE = 512
27
29
  DIRECT_MAX_REQUEST_BATCH_SIZE = 8
@@ -314,13 +316,35 @@ module Bitfab
314
316
  end
315
317
 
316
318
  def send_batch(envelope, batch)
317
- if batch.size > @max_request_bytes
318
- warn_oversized(batch.spans.size)
319
- return false
320
- end
319
+ request_spans = batch.spans
320
+ request_raw_bytes = batch.size
321
+ already_trimmed = false
322
+ loop do
323
+ if request_raw_bytes <= MAX_DECOMPRESSED_REQUEST_BYTES
324
+ prepared = Compress.prepare_request_body(Encoder.encode_request(envelope, request_spans))
325
+ if prepared.wire_bytes <= @max_request_bytes
326
+ send_with_retries(prepared)
327
+ return true
328
+ end
329
+ end
321
330
 
322
- send_with_retries(Encoder.encode_request(envelope, batch.spans))
323
- true
331
+ if batch.spans.size != 1
332
+ warn_oversized(batch.spans.size)
333
+ return false
334
+ end
335
+ if already_trimmed
336
+ warn_oversized(1)
337
+ return false
338
+ end
339
+ trimmed = Encoder.trim_span(batch.spans.first)
340
+ if trimmed.nil?
341
+ warn_oversized(1)
342
+ return false
343
+ end
344
+ request_spans = [trimmed]
345
+ request_raw_bytes = envelope.size + trimmed.size
346
+ already_trimmed = true
347
+ end
324
348
  rescue PayloadTooLargeError
325
349
  warn_oversized(batch.spans.size)
326
350
  false
@@ -343,11 +367,11 @@ module Bitfab
343
367
  )
344
368
  end
345
369
 
346
- def send_with_retries(body)
370
+ def send_with_retries(request)
347
371
  attempt = 0
348
372
  begin
349
373
  attempt += 1
350
- response = @direct_sender.call(OTLP_TRACES_ENDPOINT, body, EXPORT_TIMEOUT_MILLIS / 1000.0)
374
+ response = @direct_sender.call(OTLP_TRACES_ENDPOINT, request, EXPORT_TIMEOUT_MILLIS / 1000.0)
351
375
  check_partial_success(response)
352
376
  rescue PartialSuccessError
353
377
  raise
@@ -456,7 +480,10 @@ module Bitfab
456
480
  # Encoding is the expensive part of a submit and needs no mutual
457
481
  # exclusion, so it stays outside the lock.
458
482
  name = Encoder.span_name(operation, payload)
459
- encoded_payload = Serialize.safe_generate(payload)
483
+ encoded_payload = Serialize.safe_generate(
484
+ payload,
485
+ max_carrier_bytes: PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
486
+ )
460
487
  started_at = Encoder.timestamp(payload, "started_at")
461
488
  ended_at = Encoder.timestamp(payload, "ended_at")
462
489
  errored = Encoder.error?(payload)
@@ -616,6 +643,20 @@ module Bitfab
616
643
  EncodedSpan.new(encoded, encoded.bytesize)
617
644
  end
618
645
 
646
+ def trim_span(span)
647
+ carrier = JSON.parse(span.encoded)
648
+ attribute = carrier.fetch("attributes").find { |entry| entry["key"] == PAYLOAD_ATTRIBUTE }
649
+ return nil if attribute.nil?
650
+
651
+ value = attribute.fetch("value")
652
+ payload = JSON.parse(value.fetch("stringValue"))
653
+ value["stringValue"] = Serialize.safe_generate(payload)
654
+ encoded = JSON.generate(carrier)
655
+ EncodedSpan.new(encoded, encoded.bytesize)
656
+ rescue KeyError, JSON::ParserError, TypeError
657
+ nil
658
+ end
659
+
619
660
  def request_envelope(first)
620
661
  scope = first.instrumentation_scope
621
662
  resource = JSON.generate({"attributes" => attributes(first.resource.attribute_enumerator.to_h)})
@@ -19,10 +19,12 @@ module Bitfab
19
19
  # on a body sized exactly to a 2.4 MB cap, prose produced a 2.4 MB carrier but
20
20
  # backslash-dense content produced 4.8 MB, which the exporter dropped.
21
21
  #
22
- # 2.8 MB leaves room beneath the 3 MB request ceiling for the span and request
23
- # envelopes wrapped around the attribute.
22
+ # The normal 2.8 MB fallback leaves room beneath the 3 MB wire target. Trace
23
+ # transport may first preserve a carrier up to 7.8 MB when its single-span
24
+ # request compresses below that target and stays under the 8 MB raw ceiling.
24
25
  module PayloadBudget
25
26
  MAX_SPAN_CARRIER_BYTES = 2_800_000
27
+ MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES = 7_800_000
26
28
 
27
29
  # Span fields that identify the span rather than carry user data. Trimming
28
30
  # one would leave a span that no longer says what it is, so they stay
@@ -47,23 +49,23 @@ module Bitfab
47
49
  # can at most double it, so anything under half the budget always fits and
48
50
  # anything past the budget never does. Ordinary spans settle on the first
49
51
  # comparison and never pay for the scan.
50
- def fits_carrier_budget?(body)
52
+ def fits_carrier_budget?(body, max_bytes = MAX_SPAN_CARRIER_BYTES)
51
53
  size = body.bytesize
52
- return true if size * 2 + 2 <= MAX_SPAN_CARRIER_BYTES
53
- return false if size + 2 > MAX_SPAN_CARRIER_BYTES
54
+ return true if size * 2 + 2 <= max_bytes
55
+ return false if size + 2 > max_bytes
54
56
 
55
- carrier_byte_length(body) <= MAX_SPAN_CARRIER_BYTES
57
+ carrier_byte_length(body) <= max_bytes
56
58
  end
57
59
 
58
60
  # Return a body within the budget, plus the fields that had to be stubbed.
59
- def enforce(payload, body, &encode)
60
- return [body, []] if fits_carrier_budget?(body)
61
+ def enforce(payload, body, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
62
+ return [body, []] if fits_carrier_budget?(body, max_bytes)
61
63
  return [body, []] unless payload.is_a?(Hash)
62
64
 
63
- trimmed_payload, trimmed = trim(payload, &encode)
65
+ trimmed_payload, trimmed = trim(payload, max_bytes, &encode)
64
66
  return [body, []] if trimmed_payload.nil?
65
67
 
66
- mark_trimmed(trimmed_payload, trimmed)
68
+ mark_trimmed(trimmed_payload, trimmed, max_bytes)
67
69
  [encode.call(trimmed_payload), trimmed]
68
70
  rescue
69
71
  [body, []]
@@ -73,7 +75,7 @@ module Bitfab
73
75
  # Returns [trimmed_payload, trimmed_keys], or nil when nothing could be
74
76
  # trimmed: the caller then ships the oversized body and lets the exporter
75
77
  # report the drop, which still beats silently emptying a span.
76
- def trim(payload, &encode)
78
+ def trim(payload, max_bytes = MAX_SPAN_CARRIER_BYTES, &encode)
77
79
  copy, containers = clone_trimmable(payload)
78
80
  candidates = collect_candidates(containers)
79
81
  return nil if candidates.empty?
@@ -83,7 +85,7 @@ module Bitfab
83
85
  container[key] = "<unserializable: too_large_#{size}_bytes>"
84
86
  trimmed << key
85
87
  body = encode.call(copy)
86
- return [copy, trimmed] if fits_carrier_budget?(body)
88
+ return [copy, trimmed] if fits_carrier_budget?(body, max_bytes)
87
89
  end
88
90
  nil
89
91
  end
@@ -135,7 +137,7 @@ module Bitfab
135
137
 
136
138
  # Record the trim in the payload's own errors, which is what the server
137
139
  # reads to flag a trace as incomplete.
138
- def mark_trimmed(payload, trimmed)
140
+ def mark_trimmed(payload, trimmed, max_bytes)
139
141
  key = payload.key?(:errors) ? :errors : "errors"
140
142
  existing = payload[key]
141
143
  errors = existing.is_a?(Array) ? existing.dup : []
@@ -143,7 +145,7 @@ module Bitfab
143
145
  "source" => "sdk",
144
146
  "step" => "payload_budget",
145
147
  "error" => "trimmed oversized field(s) to fit the " \
146
- "#{MAX_SPAN_CARRIER_BYTES}-byte span carrier budget: " \
148
+ "#{max_bytes}-byte span carrier budget: " \
147
149
  "#{trimmed.uniq.join(", ")}"
148
150
  }
149
151
  payload[key] = errors
@@ -14,7 +14,7 @@ module Bitfab
14
14
  # further. It is deliberately the same number as the whole-span budget: one
15
15
  # legitimately large value may use the entire budget, and PayloadBudget is
16
16
  # what enforces the total once every field is in.
17
- MAX_SERIALIZED_BYTES = PayloadBudget::MAX_SPAN_CARRIER_BYTES
17
+ MAX_SERIALIZED_BYTES = PayloadBudget::MAX_COMPRESSIBLE_SPAN_CARRIER_BYTES
18
18
 
19
19
  # Recursion guard for cyclic graphs and pathologically nested structures.
20
20
  MAX_SERIALIZE_DEPTH = 16
@@ -260,18 +260,20 @@ module Bitfab
260
260
  # raises and stubs strays) instead of letting JSON.generate raise and drop
261
261
  # the whole span/trace silently. A degraded payload warns loudly so the
262
262
  # trace isn't quietly left incomplete or not replayable.
263
- def safe_generate(payload)
263
+ def safe_generate(payload, max_carrier_bytes: PayloadBudget::MAX_SPAN_CARRIER_BYTES)
264
264
  # Budget the value that was actually encoded, not the caller's original: a
265
265
  # cyclic or otherwise non-encodable field cannot be sized (JSON.generate
266
266
  # raises on it), so on the original graph the biggest field is skipped as
267
267
  # a trim candidate and the oversized body ships anyway. The sanitized copy
268
268
  # has those values already replaced with stubs, so every field is sizeable.
269
269
  body, encoded = encode_unbounded(payload)
270
- body, trimmed = PayloadBudget.enforce(encoded, body) { |value| encode_unbounded(value).first }
270
+ body, trimmed = PayloadBudget.enforce(encoded, body, max_carrier_bytes) do |value|
271
+ encode_unbounded(value).first
272
+ end
271
273
  if trimmed.any?
272
274
  Bitfab.warn_once(
273
275
  "payload-over-budget",
274
- "a span payload exceeded the #{PayloadBudget::MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its " \
276
+ "a span payload exceeded the #{max_carrier_bytes}-byte carrier budget; its " \
275
277
  "largest field(s) (#{trimmed.uniq.join(", ")}) were replaced with placeholders " \
276
278
  "so the span still ships. The span is incomplete and may not be replayable."
277
279
  )
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.36.4"
4
+ VERSION = "0.36.5"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bitfab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.36.4
4
+ version: 0.36.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team