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 +4 -4
- data/lib/bitfab/compress.rb +29 -0
- data/lib/bitfab/http_client.rb +4 -1
- data/lib/bitfab/payload_budget.rb +152 -0
- data/lib/bitfab/serialize.rb +46 -9
- data/lib/bitfab/version.rb +1 -1
- data/lib/bitfab.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b5fdfbac8e62c59768ba554cd7c68b6fb7de3f1a37befd01a66cc6097821499
|
|
4
|
+
data.tar.gz: 31bb825d1fcde3c946c5467bb6e1ccbd56816815b38ba4f3eb5395bdb5061907
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/bitfab/http_client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/bitfab/serialize.rb
CHANGED
|
@@ -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
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
300
|
+
marker = {"error" => "payload_serialize_failed"}
|
|
301
|
+
[JSON.generate(marker), marker]
|
|
265
302
|
end
|
|
266
303
|
end
|
|
267
304
|
|
data/lib/bitfab/version.rb
CHANGED
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.
|
|
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
|