bitfab 0.34.0 → 0.36.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 897fb65140b86333bb5012746e570c785560c7865cd79da1e872e4ec567fada2
4
- data.tar.gz: 26b89a73668a0f86746a349c8f57fe39317f069dbab04dd8731ff91950d24a49
3
+ metadata.gz: 1b5fdfbac8e62c59768ba554cd7c68b6fb7de3f1a37befd01a66cc6097821499
4
+ data.tar.gz: 31bb825d1fcde3c946c5467bb6e1ccbd56816815b38ba4f3eb5395bdb5061907
5
5
  SHA512:
6
- metadata.gz: cb837f5a94d68391c0b25ac1bc073cba636816270abd623b825b59a2a3d8f387ac67a49aaef93aebcc3201ad24dbe4ef9f4d4738955d5db5387181eb1f94ee68
7
- data.tar.gz: 0b609be82dca49043083bf966b52b01466f8252abe15b243e4d2ac6a3ea06d5238fb5fdf7b88e7a2c1be22fa4c36b06425baa2261a62ffed6967b5e8c86a8150
6
+ metadata.gz: efd78672cf98417507533b22bc0d4783ad7c29a18c4e9f419d95e328831275c26b4d15888642f07e19cd7a85b62babe55e223576d6a4885228fea4243ab84607
7
+ data.tar.gz: 83dc9683122eefbe92fc747d783fb81351acc6ccef94e1c31f5ae2b391adac9d775832d9f56818cde2541817a4b8cde475cda44021b93a5f23bcbac9a010904a
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Bitfab
6
+ # Request body compression for Bitfab API requests.
7
+ module Compress
8
+ DISABLE_COMPRESSION_ENV = "BITFAB_DISABLE_COMPRESSION"
9
+
10
+ # Below this, compressing costs more than the saved bytes are worth, so
11
+ # small requests (function lookups, replay status polls, single-span
12
+ # batches) ride uncompressed.
13
+ MIN_COMPRESSED_BYTES = 8_192
14
+
15
+ module_function
16
+
17
+ # Returns the body to send and the Content-Encoding it carries, or nil when
18
+ # the body is sent as-is. Compression is best-effort: any failure sends the
19
+ # original body rather than dropping the span.
20
+ 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
+
24
+ [Zlib.gzip(body), "gzip"]
25
+ rescue
26
+ [body, nil]
27
+ end
28
+ end
29
+ end
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
6
 
7
+ require_relative "compress"
7
8
  require_relative "constants"
8
9
  require_relative "serialize"
9
10
  require_relative "transport"
@@ -69,7 +70,9 @@ module Bitfab
69
70
  http.read_timeout = request_timeout
70
71
 
71
72
  req = Net::HTTP::Post.new(uri.path, headers)
72
- req.body = body
73
+ encoded_body, content_encoding = Compress.encode_request_body(body)
74
+ req["Content-Encoding"] = content_encoding if content_encoding
75
+ req.body = encoded_body
73
76
 
74
77
  response = http.request(req)
75
78
 
@@ -0,0 +1,152 @@
1
+ require "json"
2
+
3
+ module Bitfab
4
+ # The ceiling on a span's encoded payload, and the trimming that enforces it.
5
+ #
6
+ # A span's whole payload (input, output, contexts, prompt, metadata) ships as
7
+ # a single bitfab.payload string attribute, and the exporter drops any carrier
8
+ # that exceeds the per-request byte ceiling outright rather than trimming it.
9
+ # Capping each value on its own cannot prevent that: two values that each fit
10
+ # can still add up to an undeliverable span. So the budget is enforced on the
11
+ # encoded payload as a whole, and an oversized span ships with its largest
12
+ # fields stubbed instead of vanishing.
13
+ #
14
+ # The budget is measured on the *carrier* (the payload re-escaped into the
15
+ # OTLP attribute), not on the payload body, because the carrier is what the
16
+ # exporter weighs. Bounding the body instead leaves escape-heavy content to
17
+ # blow the request ceiling anyway: a body of escaped JSON, Windows paths, or
18
+ # regexes is nearly all backslashes, and every one of them doubles. Measured
19
+ # on a body sized exactly to a 2.4 MB cap, prose produced a 2.4 MB carrier but
20
+ # backslash-dense content produced 4.8 MB, which the exporter dropped.
21
+ #
22
+ # 2.8 MB leaves room beneath the 3 MB request ceiling for the span and request
23
+ # envelopes wrapped around the attribute.
24
+ module PayloadBudget
25
+ MAX_SPAN_CARRIER_BYTES = 2_800_000
26
+
27
+ # Span fields that identify the span rather than carry user data. Trimming
28
+ # one would leave a span that no longer says what it is, so they stay
29
+ # whatever the payload costs.
30
+ STRUCTURAL_SPAN_KEYS = ["name", "type", "function_name", "error_source"].freeze
31
+
32
+ module_function
33
+
34
+ # The byte length +body+ occupies once re-escaped as a JSON string value.
35
+ #
36
+ # +body+ is itself JSON text, so the first encode already replaced every
37
+ # control character with a \uXXXX sequence. Only " and \ are left to
38
+ # escape, and each costs exactly one more byte, which bounds the expansion
39
+ # at 2x and is what lets .fits_carrier_budget? skip this scan for all but
40
+ # the largest payloads.
41
+ def carrier_byte_length(body)
42
+ body.bytesize + body.count("\"\\\\") + 2
43
+ end
44
+
45
+ # Whether +body+ fits the carrier budget, measuring exactly only when the
46
+ # cheap bounds cannot already decide it. Escaping never shrinks the body and
47
+ # can at most double it, so anything under half the budget always fits and
48
+ # anything past the budget never does. Ordinary spans settle on the first
49
+ # comparison and never pay for the scan.
50
+ def fits_carrier_budget?(body)
51
+ 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
+
55
+ carrier_byte_length(body) <= MAX_SPAN_CARRIER_BYTES
56
+ end
57
+
58
+ # 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
+ return [body, []] unless payload.is_a?(Hash)
62
+
63
+ trimmed_payload, trimmed = trim(payload, &encode)
64
+ return [body, []] if trimmed_payload.nil?
65
+
66
+ mark_trimmed(trimmed_payload, trimmed)
67
+ [encode.call(trimmed_payload), trimmed]
68
+ rescue
69
+ [body, []]
70
+ end
71
+
72
+ # Stub the largest payload fields until the encoded body fits the budget.
73
+ # Returns [trimmed_payload, trimmed_keys], or nil when nothing could be
74
+ # trimmed: the caller then ships the oversized body and lets the exporter
75
+ # report the drop, which still beats silently emptying a span.
76
+ def trim(payload, &encode)
77
+ copy, containers = clone_trimmable(payload)
78
+ candidates = collect_candidates(containers)
79
+ return nil if candidates.empty?
80
+
81
+ trimmed = []
82
+ candidates.each do |container, key, size|
83
+ container[key] = "<unserializable: too_large_#{size}_bytes>"
84
+ trimmed << key
85
+ body = encode.call(copy)
86
+ return [copy, trimmed] if fits_carrier_budget?(body)
87
+ end
88
+ nil
89
+ end
90
+
91
+ # Copy the records holding user data so trimming never mutates the caller's.
92
+ def clone_trimmable(payload)
93
+ copy = payload.dup
94
+ containers = []
95
+
96
+ span_data = copy["span_data"] || copy[:span_data]
97
+ if span_data.is_a?(Hash)
98
+ clone = span_data.dup
99
+ copy[copy.key?("span_data") ? "span_data" : :span_data] = clone
100
+ containers << clone
101
+ end
102
+
103
+ raw_span = copy["rawSpan"] || copy[:rawSpan]
104
+ raw_span_data = raw_span.is_a?(Hash) ? (raw_span["span_data"] || raw_span[:span_data]) : nil
105
+ if raw_span_data.is_a?(Hash)
106
+ clone = raw_span_data.dup
107
+ raw_span_copy = raw_span.dup
108
+ raw_span_copy[raw_span.key?("span_data") ? "span_data" : :span_data] = clone
109
+ copy[copy.key?("rawSpan") ? "rawSpan" : :rawSpan] = raw_span_copy
110
+ containers << clone
111
+ end
112
+
113
+ # No span_data anywhere: a trace-level or otherwise unfamiliar payload.
114
+ # Trim its own fields rather than give up, so an oversized body still
115
+ # ships.
116
+ containers << copy if containers.empty?
117
+
118
+ [copy, containers]
119
+ end
120
+
121
+ def collect_candidates(containers)
122
+ candidates = containers.flat_map do |container|
123
+ container.filter_map do |key, value|
124
+ next if STRUCTURAL_SPAN_KEYS.include?(key.to_s) || value.nil?
125
+
126
+ begin
127
+ [container, key, JSON.generate(value).bytesize]
128
+ rescue
129
+ next
130
+ end
131
+ end
132
+ end
133
+ candidates.sort_by { |entry| -entry[2] }
134
+ end
135
+
136
+ # Record the trim in the payload's own errors, which is what the server
137
+ # reads to flag a trace as incomplete.
138
+ def mark_trimmed(payload, trimmed)
139
+ key = payload.key?(:errors) ? :errors : "errors"
140
+ existing = payload[key]
141
+ errors = existing.is_a?(Array) ? existing.dup : []
142
+ errors << {
143
+ "source" => "sdk",
144
+ "step" => "payload_budget",
145
+ "error" => "trimmed oversized field(s) to fit the " \
146
+ "#{MAX_SPAN_CARRIER_BYTES}-byte span carrier budget: " \
147
+ "#{trimmed.uniq.join(", ")}"
148
+ }
149
+ payload[key] = errors
150
+ end
151
+ end
152
+ end
@@ -4,13 +4,17 @@ require "base64"
4
4
  require "json"
5
5
  require "time"
6
6
 
7
+ require_relative "payload_budget"
8
+
7
9
  module Bitfab
8
10
  module Serialize
9
- # Cap on serialized payload size. Walking arbitrary objects (e.g. an
10
- # OpenAI client passed as a span input) can produce hundreds of KB to MB
11
- # of useless internal state. Anything beyond this cap is replaced with a
12
- # stub so the span still ships and the trace isn't dropped server-side.
13
- MAX_SERIALIZED_BYTES = 512_000
11
+ # Cap on a single serialized value. Walking arbitrary objects can produce
12
+ # hundreds of KB to MB of useless internal state, so this is the cheap
13
+ # early-out that stops the walk before a pathological object is carried any
14
+ # further. It is deliberately the same number as the whole-span budget: one
15
+ # legitimately large value may use the entire budget, and PayloadBudget is
16
+ # what enforces the total once every field is in.
17
+ MAX_SERIALIZED_BYTES = PayloadBudget::MAX_SPAN_CARRIER_BYTES
14
18
 
15
19
  # Recursion guard for cyclic graphs and pathologically nested structures.
16
20
  MAX_SERIALIZE_DEPTH = 16
@@ -57,7 +61,17 @@ module Bitfab
57
61
  end
58
62
 
59
63
  case value
60
- when nil, true, false, Integer, Float, String
64
+ when Float
65
+ # JSON has no NaN/Infinity literal, so Ruby's generator raises on one.
66
+ # Left unstubbed it kills the encode for the whole batch, not just this
67
+ # span. Mirrors the Python SDK's non_finite_float placeholder.
68
+ if value.finite?
69
+ value
70
+ else
71
+ dropped << "non_finite_float"
72
+ "<unserializable: non_finite_float>"
73
+ end
74
+ when nil, true, false, Integer, String
61
75
  value
62
76
  when Hash
63
77
  value.each_with_object({}) do |(k, v), acc|
@@ -247,7 +261,28 @@ module Bitfab
247
261
  # the whole span/trace silently. A degraded payload warns loudly so the
248
262
  # trace isn't quietly left incomplete or not replayable.
249
263
  def safe_generate(payload)
250
- JSON.generate(payload)
264
+ # Budget the value that was actually encoded, not the caller's original: a
265
+ # cyclic or otherwise non-encodable field cannot be sized (JSON.generate
266
+ # raises on it), so on the original graph the biggest field is skipped as
267
+ # a trim candidate and the oversized body ships anyway. The sanitized copy
268
+ # has those values already replaced with stubs, so every field is sizeable.
269
+ body, encoded = encode_unbounded(payload)
270
+ body, trimmed = PayloadBudget.enforce(encoded, body) { |value| encode_unbounded(value).first }
271
+ if trimmed.any?
272
+ Bitfab.warn_once(
273
+ "payload-over-budget",
274
+ "a span payload exceeded the #{PayloadBudget::MAX_SPAN_CARRIER_BYTES}-byte carrier budget; its " \
275
+ "largest field(s) (#{trimmed.uniq.join(", ")}) were replaced with placeholders " \
276
+ "so the span still ships. The span is incomplete and may not be replayable."
277
+ )
278
+ end
279
+ body
280
+ end
281
+
282
+ # Returns [body, encoded_value] where encoded_value is what the body was
283
+ # generated from: the payload itself, or its sanitized copy.
284
+ def encode_unbounded(payload)
285
+ [JSON.generate(payload), payload]
251
286
  rescue => e
252
287
  Bitfab.warn_once(
253
288
  "request-body-stubbed",
@@ -258,10 +293,12 @@ module Bitfab
258
293
  )
259
294
 
260
295
  begin
261
- JSON.generate(sanitize_payload(payload))
296
+ sanitized = sanitize_payload(payload)
297
+ [JSON.generate(sanitized), sanitized]
262
298
  rescue
263
299
  # Truly pathological. Still never drop silently: send a marker body.
264
- JSON.generate({"error" => "payload_serialize_failed"})
300
+ marker = {"error" => "payload_serialize_failed"}
301
+ [JSON.generate(marker), marker]
265
302
  end
266
303
  end
267
304
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.34.0"
4
+ VERSION = "0.36.0"
5
5
  end
data/lib/bitfab.rb CHANGED
@@ -5,6 +5,7 @@ require "json"
5
5
  require_relative "bitfab/version"
6
6
  require_relative "bitfab/constants"
7
7
  require_relative "bitfab/warn_once"
8
+ require_relative "bitfab/payload_budget"
8
9
  require_relative "bitfab/serialize"
9
10
  require_relative "bitfab/db_snapshot"
10
11
  require_relative "bitfab/span_context"
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.34.0
4
+ version: 0.36.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -138,11 +138,13 @@ files:
138
138
  - README.md
139
139
  - lib/bitfab.rb
140
140
  - lib/bitfab/client.rb
141
+ - lib/bitfab/compress.rb
141
142
  - lib/bitfab/constants.rb
142
143
  - lib/bitfab/db_snapshot.rb
143
144
  - lib/bitfab/http_client.rb
144
145
  - lib/bitfab/mock_override.rb
145
146
  - lib/bitfab/otel.rb
147
+ - lib/bitfab/payload_budget.rb
146
148
  - lib/bitfab/replay.rb
147
149
  - lib/bitfab/replay_branch.rb
148
150
  - lib/bitfab/serialize.rb