bitfab 0.21.0 → 0.21.2

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: 7536f774b491c131b04a135a41c5f753c7c507615e54aa675c314cff785b08df
4
- data.tar.gz: 47e7804161bc40563f5aca99147ef43d2af1af6d73d22f6ad3ae3402ea4b51f4
3
+ metadata.gz: 910352d2c5434b3fae7c3c56ccfea74dc0a22ce35d0cb4d2cb9d463e4ba3660b
4
+ data.tar.gz: 2e4e07a7447b75404e753f9a2d45350edfbee088ba100aff06031cf477cbc59c
5
5
  SHA512:
6
- metadata.gz: 4aed7d077e6aa0ca89e8130b526578276591e60b41ae10670dc14db4eaca6221c30481dcad84acb8d7dfd7e549b2b92aa2c5a750efa36c4df85224b01360fe19
7
- data.tar.gz: d73a3933eceba9c705fdab9f8b38f9f46766e026e59cdf61ee0ca2ea49a01ec4e5029fcbc87161a0a16602a2365ef3eeddba1acff27e659bf8fcf69b08d85138
6
+ metadata.gz: 1d197beb80ef3dcd1e57a8aebbe3aafd39ed000cadb0e1098ca71369d69ba53d4e805626f632b3bfdfb5f0cbe36d12e34d589f0b3def09be7e397bb1363e8b5e
7
+ data.tar.gz: 2f35b7eeba42eb86005e8cb9af3f46654313a84bc375e3c0edbd04e716cd8c511d0d302c2626d5f679b663f3482a24d8ec9b91d9b653fb2da6412aea0da2280b
data/lib/bitfab/client.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "http_client"
8
8
  require_relative "replay"
9
9
  require_relative "span_context"
10
10
  require_relative "serialize"
11
+ require_relative "warn_once"
11
12
 
12
13
  module Bitfab
13
14
  class Client
@@ -101,59 +102,93 @@ module Bitfab
101
102
  mock_on_replay: false)
102
103
  return yield unless @enabled
103
104
 
104
- parent = SpanContext.current
105
- replay_ctx = ReplayContext.current
106
- trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
107
- span_id = SecureRandom.uuid
108
- parent_span_id = parent&.dig(:span_id)
109
- is_root_span = parent_span_id.nil?
110
- started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
111
- resolved_test_run_id = replay_ctx&.dig(:test_run_id)
112
- resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
113
- resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
114
-
115
- # Register trace state for root spans
116
- if is_root_span && !TraceState.get(trace_id)
117
- TraceState.create(
118
- trace_id,
119
- test_run_id: resolved_test_run_id,
120
- input_source_trace_id: resolved_input_source_trace_id
121
- )
122
- end
105
+ # Span setup runs before the user's block. Tracing is a side-channel, so
106
+ # if anything here raises (id generation, trace-state bookkeeping, a
107
+ # malformed replay mock tree) the user's method must still run. On failure
108
+ # we clean up any partially registered trace state, warn once, and run the
109
+ # block untraced. `trace_id` is declared out here so the rescue can clean
110
+ # it up; the other locals stay visible to the real path below.
111
+ trace_id = nil
112
+ span_id = nil
113
+ parent_span_id = nil
114
+ is_root_span = nil
115
+ started_at = nil
116
+ resolved_test_run_id = nil
117
+ resolved_input_source_span_id = nil
118
+ begin
119
+ parent = SpanContext.current
120
+ replay_ctx = ReplayContext.current
121
+ trace_id = parent ? parent[:trace_id] : (replay_ctx&.dig(:trace_id) || SecureRandom.uuid)
122
+ span_id = SecureRandom.uuid
123
+ parent_span_id = parent&.dig(:span_id)
124
+ is_root_span = parent_span_id.nil?
125
+ started_at = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
126
+ resolved_test_run_id = replay_ctx&.dig(:test_run_id)
127
+ resolved_input_source_span_id = replay_ctx&.dig(:input_source_span_id)
128
+ resolved_input_source_trace_id = replay_ctx&.dig(:input_source_trace_id)
129
+
130
+ # Register trace state for root spans
131
+ if is_root_span && !TraceState.get(trace_id)
132
+ TraceState.create(
133
+ trace_id,
134
+ test_run_id: resolved_test_run_id,
135
+ input_source_trace_id: resolved_input_source_trace_id
136
+ )
137
+ end
123
138
 
124
- if is_root_span
125
- @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
126
- end
139
+ if is_root_span
140
+ @pending_span_mutex.synchronize { @pending_span_threads[trace_id] = [] }
141
+ end
127
142
 
128
- # Advance the per-(key, name) call counter for any non-root span under
129
- # an active mock tree, even when this span won't itself be mocked.
130
- # Unmarked spans must consume an index so subsequent marked siblings
131
- # line up with `build_mock_tree`'s sequential numbering for the same
132
- # (key, name) pair. Different (key, name) pairs have independent
133
- # counters: they cannot shift each other.
134
- call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
135
- if call_index
136
- mocked_output = check_mock_replay(
137
- replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
138
- )
139
- if mocked_output != MOCK_REPLAY_MISS
140
- send_mocked_span(
141
- trace_function_key:,
142
- trace_id:,
143
- span_id:,
144
- parent_span_id:,
145
- span_name:,
146
- span_type:,
147
- function_name:,
148
- args:,
149
- kwargs:,
150
- mocked_output:,
151
- started_at:,
152
- test_run_id: resolved_test_run_id,
153
- input_source_span_id: resolved_input_source_span_id
143
+ # Advance the per-(key, name) call counter for any non-root span under
144
+ # an active mock tree, even when this span won't itself be mocked.
145
+ # Unmarked spans must consume an index so subsequent marked siblings
146
+ # line up with `build_mock_tree`'s sequential numbering for the same
147
+ # (key, name) pair. Different (key, name) pairs have independent
148
+ # counters: they cannot shift each other.
149
+ call_index = advance_mock_counter(replay_ctx, trace_function_key, span_name, is_root_span:)
150
+ if call_index
151
+ mocked_output = check_mock_replay(
152
+ replay_ctx, trace_function_key, span_name, call_index, mock_on_replay:
154
153
  )
155
- return mocked_output
154
+ if mocked_output != MOCK_REPLAY_MISS
155
+ send_mocked_span(
156
+ trace_function_key:,
157
+ trace_id:,
158
+ span_id:,
159
+ parent_span_id:,
160
+ span_name:,
161
+ span_type:,
162
+ function_name:,
163
+ args:,
164
+ kwargs:,
165
+ mocked_output:,
166
+ started_at:,
167
+ test_run_id: resolved_test_run_id,
168
+ input_source_span_id: resolved_input_source_span_id
169
+ )
170
+ return mocked_output
171
+ end
172
+ end
173
+ rescue
174
+ # Clean up any trace state this partial setup registered so it does not
175
+ # leak.
176
+ if trace_id
177
+ TraceState.delete(trace_id)
178
+ @pending_span_mutex.synchronize { @pending_span_threads.delete(trace_id) }
156
179
  end
180
+ # During replay (a controlled eval) a setup failure must surface, not
181
+ # silently run the block untraced: swallowing it would execute real code
182
+ # with real side effects and skew the mock call counter, defeating the
183
+ # replay. The never-crash fallback is for production hosts only.
184
+ raise if ReplayContext.current
185
+
186
+ Bitfab.warn_once(
187
+ "span-setup:#{trace_function_key}",
188
+ "span setup failed for '#{trace_function_key}'; this call runs untraced. " \
189
+ "Your method still executes and returns normally."
190
+ )
191
+ return yield
157
192
  end
158
193
 
159
194
  result = nil
@@ -390,9 +425,12 @@ module Bitfab
390
425
  def send_span(trace_function_key:, trace_id:, span_id:, parent_span_id:,
391
426
  span_name:, span_type:, function_name:, contexts:, prompt:, args:, kwargs:, result:, error:,
392
427
  started_at:, ended_at:, test_run_id: nil, input_source_span_id: nil)
393
- # Human-readable JSON (input/output fields)
394
- human_inputs = Serialize.serialize_inputs(args, kwargs)
395
- human_output = Serialize.serialize_value(result)
428
+ # Human-readable JSON (input/output fields). The *_with_report variants
429
+ # also report what could not be faithfully captured so a lossy span is
430
+ # marked non-replayable below instead of shipping as if it round-trips.
431
+ human_inputs, input_dropped = Serialize.serialize_inputs_with_report(args, kwargs)
432
+ human_output, output_dropped = Serialize.serialize_value_with_report(result)
433
+ dropped = input_dropped + output_dropped
396
434
 
397
435
  # Marshal + Base64 for full Ruby-to-Ruby object reconstruction
398
436
  raw_input = (args.length == 1 && kwargs.empty?) ? args[0] : {args:, kwargs:}
@@ -434,6 +472,18 @@ module Bitfab
434
472
  }
435
473
  payload["testRunId"] = test_run_id if test_run_id
436
474
 
475
+ # A value that could only be captured as a placeholder makes the span
476
+ # non-replayable; record it on the payload's errors (matching the Python
477
+ # and TypeScript SDKs) instead of shipping the lossy capture silently.
478
+ unless dropped.empty?
479
+ names = dropped.uniq.sort.join(", ")
480
+ payload["errors"] = [{
481
+ "source" => "sdk",
482
+ "step" => "serialization_degraded",
483
+ "error" => "non-replayable: could not faithfully capture #{names}"
484
+ }]
485
+ end
486
+
437
487
  @http_client.send_external_span(payload) # Returns the background thread
438
488
  end
439
489
 
@@ -7,6 +7,7 @@ require "uri"
7
7
  require_relative "constants"
8
8
  require_relative "serialize"
9
9
  require_relative "version"
10
+ require_relative "warn_once"
10
11
 
11
12
  module Bitfab
12
13
  class HttpClient
@@ -181,14 +182,13 @@ module Bitfab
181
182
  def safe_generate(payload, endpoint)
182
183
  JSON.generate(payload)
183
184
  rescue => e
184
- begin
185
- warn "Bitfab: request body to #{endpoint} held a non-serializable " \
186
- "value (#{e.message}); it was stubbed so the span still sends, " \
187
- "but the trace may be incomplete or not replayable. Capture a " \
188
- "JSON-safe projection of this input to make it replayable."
189
- rescue
190
- # Never crash the host app over a log line.
191
- end
185
+ Bitfab.warn_once(
186
+ "request-body-stubbed",
187
+ "a request body held a non-serializable value (#{e.message}); it was " \
188
+ "stubbed so the span still sends, but the trace may be incomplete or " \
189
+ "not replayable. Capture a JSON-safe projection of this input to make " \
190
+ "it replayable."
191
+ )
192
192
 
193
193
  begin
194
194
  JSON.generate(sanitize_payload(payload))
@@ -242,11 +242,11 @@ module Bitfab
242
242
  thread = Thread.new do
243
243
  block.call
244
244
  rescue => e
245
- begin
246
- warn "Bitfab: Failed to send request: #{e.message}"
247
- rescue
248
- # Never crash the host app
249
- end
245
+ Bitfab.warn_once(
246
+ "send-request-failed",
247
+ "failed to send a request to the backend (further occurrences " \
248
+ "suppressed): #{e.message}"
249
+ )
250
250
  ensure
251
251
  @pending_threads_mutex.synchronize { @pending_threads.delete(Thread.current) }
252
252
  end
@@ -29,28 +29,44 @@ module Bitfab
29
29
  # Without this the wire-side JSON.dump in the http client can produce a
30
30
  # request that times out or gets rejected, leaving a trace with zero spans.
31
31
  def serialize_value(value)
32
- result = serialize_value_inner(value, 0)
33
- return result unless oversized?(result)
32
+ serialize_value_with_report(value).first
33
+ end
34
34
 
35
- unserializable_stub(value, "too_large")
35
+ # Like serialize_value, but also reports what could not be faithfully
36
+ # captured. Returns [result, dropped] where dropped lists the class name (or
37
+ # "max_depth"/"too_large") behind every placeholder the walk had to emit. A
38
+ # non-empty dropped means the capture is lossy, so the send boundary marks
39
+ # the span serialization_degraded (non-replayable) instead of shipping it as
40
+ # if it round-trips. Mirrors the Python/TS SDKs' report serializers.
41
+ def serialize_value_with_report(value)
42
+ dropped = []
43
+ result = serialize_value_inner(value, 0, dropped)
44
+ if oversized?(result)
45
+ dropped << "too_large"
46
+ return [unserializable_stub(value, "too_large"), dropped]
47
+ end
48
+ [result, dropped]
36
49
  rescue StandardError, SystemStackError
37
- unserializable_stub(value, "unexpected_error")
50
+ [unserializable_stub(value, "unexpected_error"), [class_name(value)]]
38
51
  end
39
52
 
40
- def serialize_value_inner(value, depth)
41
- return "<unserializable: max_depth>" if depth > MAX_SERIALIZE_DEPTH
53
+ def serialize_value_inner(value, depth, dropped = [])
54
+ if depth > MAX_SERIALIZE_DEPTH
55
+ dropped << "max_depth"
56
+ return "<unserializable: max_depth>"
57
+ end
42
58
 
43
59
  case value
44
60
  when nil, true, false, Integer, Float, String
45
61
  value
46
62
  when Hash
47
63
  value.each_with_object({}) do |(k, v), acc|
48
- acc[safe_to_s(k)] = serialize_value_inner(v, depth + 1)
64
+ acc[safe_to_s(k)] = serialize_value_inner(v, depth + 1, dropped)
49
65
  end
50
66
  when Array
51
- value.map { |v| serialize_value_inner(v, depth + 1) }
67
+ value.map { |v| serialize_value_inner(v, depth + 1, dropped) }
52
68
  when Set
53
- value.map { |v| serialize_value_inner(v, depth + 1) }
69
+ value.map { |v| serialize_value_inner(v, depth + 1, dropped) }
54
70
  when Time, DateTime
55
71
  safely_call(value, "iso8601", 3) { safe_to_s(value) }
56
72
  when Date
@@ -59,16 +75,26 @@ module Bitfab
59
75
  value.to_s
60
76
  else
61
77
  if value.respond_to?(:to_h)
62
- h = safely_call(value, "to_h") { return unserializable_stub(value, "to_h_raised") }
63
- serialize_value_inner(h, depth + 1)
78
+ h = safely_call(value, "to_h") do
79
+ dropped << class_name(value)
80
+ return unserializable_stub(value, "to_h_raised")
81
+ end
82
+ serialize_value_inner(h, depth + 1, dropped)
64
83
  elsif value.respond_to?(:to_a)
65
- a = safely_call(value, "to_a") { return unserializable_stub(value, "to_a_raised") }
66
- serialize_value_inner(a, depth + 1)
84
+ a = safely_call(value, "to_a") do
85
+ dropped << class_name(value)
86
+ return unserializable_stub(value, "to_a_raised")
87
+ end
88
+ serialize_value_inner(a, depth + 1, dropped)
67
89
  else
90
+ # An arbitrary object with no structured conversion: stringifying it is
91
+ # a lossy capture (a repr, not its data), so report it as dropped.
92
+ dropped << class_name(value)
68
93
  safe_to_s(value)
69
94
  end
70
95
  end
71
96
  rescue StandardError, SystemStackError
97
+ dropped << class_name(value)
72
98
  unserializable_stub(value, "inner_error")
73
99
  end
74
100
 
@@ -108,14 +134,27 @@ module Bitfab
108
134
 
109
135
  # Serialize function inputs (args + kwargs) for span data (human-readable).
110
136
  def serialize_inputs(args, kwargs = {})
111
- serialized = args.map { |arg| serialize_value(arg) }
137
+ serialize_inputs_with_report(args, kwargs).first
138
+ end
139
+
140
+ # Like serialize_inputs, but also returns the accumulated dropped list so the
141
+ # send boundary can mark a lossy capture non-replayable.
142
+ def serialize_inputs_with_report(args, kwargs = {})
143
+ dropped = []
144
+ serialized = args.map do |arg|
145
+ result, arg_dropped = serialize_value_with_report(arg)
146
+ dropped.concat(arg_dropped)
147
+ result
148
+ end
112
149
  unless kwargs.empty?
113
150
  kw = kwargs.each_with_object({}) do |(k, v), acc|
114
- acc[safe_to_s(k)] = serialize_value(v)
151
+ result, v_dropped = serialize_value_with_report(v)
152
+ dropped.concat(v_dropped)
153
+ acc[safe_to_s(k)] = result
115
154
  end
116
155
  serialized << kw
117
156
  end
118
- serialized
157
+ [serialized, dropped]
119
158
  end
120
159
 
121
160
  # Marshal a value to a Base64-encoded string for Ruby-to-Ruby reconstruction.
@@ -173,7 +173,12 @@ module Bitfab
173
173
 
174
174
  wrapper = Module.new do
175
175
  define_method(method_name) do |*args, **kwargs, &block|
176
- Bitfab.client.send(:execute_span,
176
+ # If Bitfab was never configured, run the real method untraced
177
+ # rather than raising a "not configured" error into the host app.
178
+ client = Bitfab.client_or_nil
179
+ return super(*args, **kwargs, &block) unless client
180
+
181
+ client.send(:execute_span,
177
182
  trace_function_key:,
178
183
  span_name:,
179
184
  span_type: type,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bitfab
4
- VERSION = "0.21.0"
4
+ VERSION = "0.21.2"
5
5
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bitfab
4
+ # Emit a warning at most once per distinct key for the life of the process.
5
+ #
6
+ # The SDK must never crash or spam the host app, so every failure on the
7
+ # user's path degrades quietly: a span is dropped, a call runs untraced, a
8
+ # payload is stubbed. Silent is safe but undebuggable; warning on every call
9
+ # from a hot path is its own problem. A one-time warning per distinct issue
10
+ # restores the signal without the flood. Keys should identify the specific
11
+ # degradation (e.g. include the trace_function_key) so each distinct issue
12
+ # warns once, not just the first one seen.
13
+ @warn_once_seen = Set.new
14
+ @warn_once_mutex = Mutex.new
15
+
16
+ class << self
17
+ def warn_once(key, message)
18
+ @warn_once_mutex.synchronize do
19
+ return if @warn_once_seen.include?(key)
20
+
21
+ @warn_once_seen.add(key)
22
+ end
23
+ begin
24
+ warn "Bitfab: #{message}"
25
+ rescue Exception # rubocop:disable Lint/RescueException
26
+ # Logging must never crash the host app (e.g. a closed $stderr).
27
+ end
28
+ end
29
+
30
+ # Test-only: clear the dedup set so a warning can fire again.
31
+ def _reset_warn_once
32
+ @warn_once_mutex.synchronize { @warn_once_seen.clear }
33
+ end
34
+ end
35
+ end
data/lib/bitfab.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "bitfab/version"
4
4
  require_relative "bitfab/constants"
5
+ require_relative "bitfab/warn_once"
5
6
  require_relative "bitfab/serialize"
6
7
  require_relative "bitfab/db_snapshot"
7
8
  require_relative "bitfab/span_context"
@@ -65,6 +66,13 @@ module Bitfab
65
66
  @client or raise "Bitfab not configured. Call Bitfab.configure(api_key: '...') first."
66
67
  end
67
68
 
69
+ # Returns the global client, or nil if not configured. Used on the traced
70
+ # call path so a method invoked before Bitfab.configure runs untraced
71
+ # rather than crashing the host app with a "not configured" error.
72
+ def client_or_nil
73
+ @client
74
+ end
75
+
68
76
  # Reset the global client (primarily for testing).
69
77
  def reset!
70
78
  @client = nil
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.21.0
4
+ version: 0.21.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Harvest Team
@@ -127,6 +127,7 @@ files:
127
127
  - lib/bitfab/span_context.rb
128
128
  - lib/bitfab/traceable.rb
129
129
  - lib/bitfab/version.rb
130
+ - lib/bitfab/warn_once.rb
130
131
  homepage: https://bitfab.ai
131
132
  licenses:
132
133
  - MIT