bitfab 0.34.1 → 0.36.1

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: 2e49c89f4d4084e04cc06422bf24853166d662ad16dcde10ff463b9b54994e44
4
- data.tar.gz: 52554692415bcf2ae1cd985dc737d797e43c93d75bbb813e77ac28f157e10193
3
+ metadata.gz: e0b827ad6e9bca08b3025f9c2419290910999a5985705369e6375aee8091232c
4
+ data.tar.gz: e6e0a414013d3bee0efda22415430d9690fe537973a36e5f3dda540fcda3d5e4
5
5
  SHA512:
6
- metadata.gz: ad413e1ed3c64b76fb1fa91e7911cb24d9b4d4e6be71528ca27cbd34e64af1b1a1d834a520c5e222259b0e7816be2b489c7ce33f6864771d32ece59c9b24e23a
7
- data.tar.gz: c119e56f3b6c81251e05e40e011746e0ec8e33598dd383ceb0e5a4b4c2f04ad4cbfb067df5fe7f7d4eaa1daf293d2bc9d0d9d5999795a385b81086721c0fe0bd
6
+ metadata.gz: ee72cf5ed5c81ad34dbd4d980091523c90c7cb1d11fc716499ab5f604f1829f5653851350942b2cea6660e20e67c065ae8807e0778153a0ccf08a1397cc08491
7
+ data.tar.gz: 297efa6c3e5d9b1086536dd5e560168e9f9d4c638638b562a67d69ccb1c37253534da6447778032f93e186532f0a563eb7b7f41148eb6b9e0e2ac91c4ed2196e
data/lib/bitfab/client.rb CHANGED
@@ -407,6 +407,7 @@ module Bitfab
407
407
  {
408
408
  neon_branch_id: lease["neonBranchId"],
409
409
  snapshot_timestamp: lease["snapshotTimestamp"],
410
+ region: lease["region"],
410
411
  original_trace_id: replay_ctx[:source_bitfab_trace_id],
411
412
  # Deprecated wire alias, kept so this SDK still reports usage
412
413
  # against servers that predate the rename.
@@ -593,6 +594,9 @@ module Bitfab
593
594
  if db_snapshot_usage[:snapshot_timestamp]
594
595
  usage["snapshot_timestamp"] = db_snapshot_usage[:snapshot_timestamp]
595
596
  end
597
+ if db_snapshot_usage[:region]
598
+ usage["region"] = db_snapshot_usage[:region]
599
+ end
596
600
  if db_snapshot_usage[:original_trace_id]
597
601
  usage["original_trace_id"] = db_snapshot_usage[:original_trace_id]
598
602
  # Deprecated wire alias, kept so this SDK still reports usage
@@ -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
data/lib/bitfab/replay.rb CHANGED
@@ -62,10 +62,12 @@ module Bitfab
62
62
  # (via +database_url+). Reported on the trace
63
63
  # completion inside the +db_snapshot_usage+ record (its +accessed+
64
64
  # field) so the server can distinguish "branch was provisioned and
65
- # exposed" from "branch URL was actually consumed". Any future
66
- # consumption path that hands the URL to customer code by other means
67
- # (e.g. a process-isolated runner writing an env overlay) must also
68
- # set this.
65
+ # exposed" from "branch URL was actually consumed". Only an explicit
66
+ # +database_url+ read may set it. A path that hands the URL over by other
67
+ # means (e.g. a process-isolated runner writing an env overlay) must
68
+ # leave it alone: setting it there would make every such replay report
69
+ # accessed for free, and the flag would stop separating "branch was
70
+ # used" from "branch was offered".
69
71
  ctx[:db_branch_lease] = db_branch_lease if db_branch_lease
70
72
  ctx[:source_bitfab_trace_id] = source_bitfab_trace_id if source_bitfab_trace_id
71
73
  Thread.current[REPLAY_CONTEXT_KEY] = ctx
@@ -17,9 +17,20 @@ module Bitfab
17
17
  # protocol term). Its useful fields are exposed directly here so customer code
18
18
  # never sees the word.
19
19
  class ReplayBranch
20
+ # The provider's own id for this branch, e.g. for correlating with its console.
21
+ attr_reader :neon_branch_id
22
+
23
+ # Env var name the customer's app reads, e.g. "DATABASE_URL".
24
+ attr_reader :env_key
25
+
20
26
  # When this branch's URL stops being valid. ISO-8601.
21
27
  attr_reader :expires_at
22
28
 
29
+ # The instant this branch is pinned to: the source trace's wall clock, read
30
+ # just before the traced method ran. Compare it against the trace you meant
31
+ # to replay to confirm the branch is the right point in history.
32
+ attr_reader :snapshot_timestamp
33
+
23
34
  # Deep link to the branch in the provider console, if available.
24
35
  attr_reader :provider_console_url
25
36
 
@@ -36,13 +47,28 @@ module Bitfab
36
47
 
37
48
  # Built by Bitfab.current_replay_branch; never constructed by callers.
38
49
  def initialize(lease, trace_id, context)
39
- @expires_at = lease["expiresAt"]
40
- @provider_console_url = lease["providerConsoleUrl"]
41
- @read_only = lease["readOnly"]
42
- @region = lease["region"]
50
+ # Copy the lease wholesale minus the connection string, so a field the
51
+ # server starts sending reaches customer code without an SDK release.
52
+ # Everything set here must stay plain data: #database_url is the only
53
+ # member allowed to mark the branch as accessed.
54
+ # The internals are underscore-prefixed because the loop below writes an
55
+ # ivar per lease key: snake_case never emits a leading underscore, so no
56
+ # field the server invents can collide with them. A plain @fields would
57
+ # be clobbered mid-loop by a key named "fields", corrupting #as_json.
58
+ fields = {}
59
+ lease.each do |key, value|
60
+ next if key == "databaseUrl"
61
+
62
+ name = snake_case(key)
63
+ fields[name] = value
64
+ instance_variable_set(:"@#{name}", value)
65
+ define_singleton_method(name) { value } unless respond_to?(name)
66
+ end
43
67
  @trace_id = trace_id
44
- @url = lease["databaseUrl"]
45
- @context = context
68
+ fields["trace_id"] = trace_id
69
+ @_fields = fields.freeze
70
+ @_url = lease["databaseUrl"]
71
+ @_context = context
46
72
  freeze
47
73
  end
48
74
 
@@ -54,15 +80,39 @@ module Bitfab
54
80
  # was actually used". The other readers inspect the lease without exposing
55
81
  # the connection string, so they deliberately do not record anything.
56
82
  def database_url
57
- @context[:db_snapshot_accessed] = true
58
- @url
83
+ @_context[:db_snapshot_accessed] = true
84
+ @_url
59
85
  end
60
86
 
61
87
  # Redact the connection string so a logged or inspected branch cannot leak it.
62
88
  def inspect
63
89
  "#<Bitfab::ReplayBranch trace_id=#{@trace_id.inspect} " \
64
90
  "expires_at=#{@expires_at.inspect} region=#{@region.inspect} " \
65
- "read_only=#{@read_only.inspect}>"
91
+ "read_only=#{@read_only.inspect} " \
92
+ "snapshot_timestamp=#{@snapshot_timestamp.inspect}>"
93
+ end
94
+
95
+ # ActiveSupport gives every object a +to_json+ that serializes its instance
96
+ # variables, so under Rails a branch reaching a log line or an API response
97
+ # would carry the connection string and the whole replay context. Serialize
98
+ # the exposed fields only, the same set +inspect+ shows.
99
+ def as_json(_options = nil)
100
+ @_fields.dup
101
+ end
102
+
103
+ def to_json(*args)
104
+ as_json.to_json(*args)
105
+ end
106
+
107
+ private
108
+
109
+ # Two passes so a run of capitals stays one word: "dbURL" is "db_url", not
110
+ # "db_u_r_l". Must match the Python SDK's conversion exactly, or the same
111
+ # server field would arrive under different names in the two SDKs.
112
+ def snake_case(key)
113
+ key.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
114
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
115
+ .downcase
66
116
  end
67
117
  end
68
118
  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
@@ -257,7 +261,28 @@ module Bitfab
257
261
  # the whole span/trace silently. A degraded payload warns loudly so the
258
262
  # trace isn't quietly left incomplete or not replayable.
259
263
  def safe_generate(payload)
260
- 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]
261
286
  rescue => e
262
287
  Bitfab.warn_once(
263
288
  "request-body-stubbed",
@@ -268,10 +293,12 @@ module Bitfab
268
293
  )
269
294
 
270
295
  begin
271
- JSON.generate(sanitize_payload(payload))
296
+ sanitized = sanitize_payload(payload)
297
+ [JSON.generate(sanitized), sanitized]
272
298
  rescue
273
299
  # Truly pathological. Still never drop silently: send a marker body.
274
- JSON.generate({"error" => "payload_serialize_failed"})
300
+ marker = {"error" => "payload_serialize_failed"}
301
+ [JSON.generate(marker), marker]
275
302
  end
276
303
  end
277
304
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.34.1"
4
+ VERSION = "0.36.1"
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.1
4
+ version: 0.36.1
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